You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(auditdb): close four locker defects found reviewing the rework
Postgres re-acquisition only ever inserted, so an enrollment ID dropped from a
live anchor kept its lease row. AssertLocksHeld and the heartbeat's renewal both
count this replica's un-expired rows for the anchor and require exactly as many
as the session recorded, so each leftover row failed both: writes were rejected
with "locks lost before write", the heartbeat gave up on its first tick, and once
the TTL passed the abandoned lease became claimable while this replica still
believed it held it. The in-memory backend released it and had a test saying so,
but the conformance suite covered only the same-set and widening cases, so
nothing compared the two backends here.
The in-memory unlockAnchor sampled whether the anchor still held anything before
releasing the anchor's lock and acted on it after taking the map lock, leaving
room for a waiter to run a whole acquisition in between and this caller to evict
an anchor that did hold permits. Nothing could reach them afterwards, so every
later audit touching those IDs blocked until its own deadline for the lifetime of
the process.
A live anchor's enrollment-ID set may now shrink or stay the same but never grow;
widening fails with ErrLockSetWidened. Deadlock freedom rests on every caller
taking shared IDs in one canonical order, and that order can only be imposed over
the IDs of a single call — an anchor that keeps earlier permits while waiting for
new ones holds locks outside it, so two anchors widening into each other's IDs
waited on each other forever, and permanently, since AcquireLocks holds the
anchor's lock across the blocking acquisition and so blocked the ReleaseLocks
that would have broken the cycle. Audit acquires once per anchor, so no caller
needs to widen. store.go no longer claims that lock ordering alone prevents
deadlock, because for incremental acquisition it did not.
The in-memory locker also had no waiting budget of its own, so a caller with no
deadline blocked forever and no failure it produced could carry
ErrLockAcquireTimeout. It now bounds itself with acquireDeadline, defaulting to
the same minute as the Postgres backend.
In the auditor, isRetriableLockError read whether the caller was gone from the
error rather than from ctx. A locker's own budget elapsing with nothing contending
surfaces as a bare context.DeadlineExceeded, so that stopped the retry loop after
one attempt at exactly the transient database failures it exists to survive. The
lock-conflict counter is now incremented only for errors carrying
ErrLockContention, as the error-classification table already promised, so
shutdown cancellations and database outages no longer inflate the metric
operators alert on.
Each fix has a test verified to fail without it, including two new conformance
cases for the shrinking and rejected-widening halves of the refresh contract and
one that deliberately passes no caller deadline, which is what hid the missing
budget.
Signed-off-by: AkramBitar <akram@il.ibm.com>
Copy file name to clipboardExpand all lines: docs/services/auditor.md
+11-1Lines changed: 11 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -87,7 +87,11 @@ Both backends implement the same contract, defined on the `Locker` interface, be
87
87
88
88
**`AssertLocksHeld` detects lost locks, not absent ones.** It fails only when a lease this replica held has since expired or been taken over. An anchor that holds nothing succeeds — whether because the request yielded no enrollment IDs, or because the caller never locked anything for it. The latter is a supported flow: an auditor may call `Validate` (which takes no locks) and then `Append`, as the dvp and nft sample views do.
89
89
90
-
**`AcquireLocks` is all-or-nothing, and never gives up ground.** A failed call holds none of the EIDs it had reached for, and leaves untouched whatever the anchor already held from an earlier successful call. Re-acquiring under a live anchor is a refresh: the EIDs it already holds are kept, new ones are added, and ones it no longer needs are released. Acquiring an *empty* set is a successful acquisition of nothing — it must not, and does not, release what the anchor is already holding.
90
+
**`AcquireLocks` is all-or-nothing, and never gives up ground.** A failed call holds none of the EIDs it had reached for, and leaves untouched whatever the anchor already held from an earlier successful call. Acquiring an *empty* set is a successful acquisition of nothing — it must not, and does not, release what the anchor is already holding.
91
+
92
+
**Re-acquiring a live anchor may shrink its EID set, never grow it.** A refresh keeps the EIDs still named and releases the ones dropped from the set; naming an EID the anchor does not already hold fails with `ErrLockSetWidened`. That restriction is what keeps the lockers deadlock-free. Deadlock freedom rests on every caller taking shared EIDs in one canonical order, and that order can only be imposed over the EIDs of a single call — an anchor that keeps earlier locks while waiting for new ones is holding locks outside it, so two anchors widening into each other's EIDs wait on each other indefinitely. `Audit` acquires once per anchor and releases when done, so no caller needs to widen.
93
+
94
+
**Each backend bounds its own waiting.** A caller that passes no deadline still gets an answer: the Postgres backend stops after `acquireDeadline`, and the in-memory backend after its own `acquireDeadline`. Without a budget of its own a backend could only ever stop when the caller's context did, and could never report `ErrLockAcquireTimeout` — the signal `auditor.Service` reads to tell "already waited in full" from "worth another attempt".
91
95
92
96
**Release always runs.**`ReleaseLocks` is idempotent, safe on an anchor that never acquired anything, and safe to `defer`. Its statement is deliberately detached from the caller's context, since the usual case is a deferred release on a context that is already cancelled and a skipped release would leave the EIDs locked against every replica until the lease TTL expired. It carries its own short internal deadline so a stuck release cannot outlive the call that issued it.
93
97
@@ -100,10 +104,16 @@ Callers act on the outcome of a failed acquisition, so both backends classify it
100
104
| Another anchor holds an EID and the waiting budget ran out |`ErrLockContention` + `ErrLockAcquireTimeout`| A real conflict, already waited out in full; retrying only adds delay |
101
105
| Another anchor holds an EID, but a transient failure or a cancelled caller ended the wait |`ErrLockContention`| A real conflict, not yet waited out; a later attempt may succeed |
102
106
| The caller cancelled or ran out of time while nothing held the EIDs | neither (plain context error) | Not a conflict, and not counted as one |
107
+
| The backend's own waiting budget elapsed while nothing held the EIDs | neither (plain context error) | Not a conflict; worth retrying while the caller's context is still live |
108
+
| The anchor asked for an EID it does not already hold |`ErrLockSetWidened`| A caller error, not a conflict: every attempt reproduces it |
103
109
| The database failed | neither; the underlying error is preserved | An infrastructure fault, reported as itself rather than as contention |
104
110
105
111
The distinction is drawn from whether an attempt actually lost a race for an EID — not from which context expired first. `acquireDeadline` defaults to a minute, so a request-scoped caller context is almost always the shorter of the two, and keying off it would mean the Postgres backend hardly ever reported contention in production.
106
112
113
+
Only the two `ErrLockContention` rows count towards `auditor_audit_lock_conflicts_total`. The rows that are not conflicts are not counted as ones, so a graceful-shutdown cancellation or a database outage does not inflate the metric operators alert on for contention.
114
+
115
+
Whether another attempt is worth making is read from the caller's context rather than from the error, because the two rows carrying a plain context error are indistinguishable by the error alone: the caller having given up is final, whereas the backend's own budget elapsing is exactly the transient case `auditor.lock` exists to retry. See `isRetriableLockError`.
116
+
107
117
### Configuration
108
118
109
119
Configure under `token.tms.<name>.auditor.locker` (see [Configuration](../configuration.md#optional-tokentmsauditorlocker)):
0 commit comments