Skip to content

Commit 37f1eaf

Browse files
committed
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>
1 parent cdf55b8 commit 37f1eaf

18 files changed

Lines changed: 840 additions & 88 deletions

File tree

docs/configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,16 @@ token:
319319
# "memory" – in-process mutex (default, single-replica only)
320320
# "postgres" – PostgreSQL lease-table (multi-replica)
321321
backend: memory
322+
# memory section is read only when backend == "memory".
323+
memory:
324+
# acquireDeadline bounds how long one acquisition waits for an EID held
325+
# by another anchor. Every backend bounds its own waiting so that a
326+
# caller which passes no deadline still gets an answer, and so that
327+
# spending the whole budget can be reported as such; auditor.lock does
328+
# not retry an acquisition that already exhausted it. Defaults to the
329+
# same 1m as the postgres backend, so switching backends does not
330+
# silently change how long an audit can block.
331+
acquireDeadline: 1m
322332
# postgres section is read only when backend == "postgres".
323333
postgres:
324334
# ttl is the lease duration for each EID lock row.

docs/services/auditor.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,11 @@ Both backends implement the same contract, defined on the `Locker` interface, be
8787

8888
**`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.
8989

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".
9195

9296
**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.
9397

@@ -100,10 +104,16 @@ Callers act on the outcome of a failed acquisition, so both backends classify it
100104
| 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 |
101105
| 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 |
102106
| 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 |
103109
| The database failed | neither; the underlying error is preserved | An infrastructure fault, reported as itself rather than as contention |
104110

105111
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.
106112

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+
107117
### Configuration
108118

109119
Configure under `token.tms.<name>.auditor.locker` (see [Configuration](../configuration.md#optional-tokentmsauditorlocker)):

token/services/auditor/auditor.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,13 @@ func (a *Service) Audit(ctx context.Context, tx Transaction) (*token.InputStream
178178
// Acquire locks with retry and exponential backoff to prevent livelock
179179
logger.DebugfContext(ctx, "audit transaction [%s], acquire locks with retry", tx.ID())
180180
if err := a.acquireLocksWithRetry(ctx, string(request.Anchor), eids); err != nil {
181-
a.metrics.AuditLockConflicts.Add(1)
181+
// Only a genuine conflict counts towards the conflict metric. Counting every
182+
// failure meant a graceful-shutdown cancellation or a database outage — neither
183+
// of which involves a second holder — inflated the one signal operators are
184+
// told to alert on for contention.
185+
if errors.Is(err, auditdb.ErrLockContention) {
186+
a.metrics.AuditLockConflicts.Add(1)
187+
}
182188

183189
return nil, nil, err
184190
}
@@ -219,7 +225,7 @@ func (a *Service) acquireLocksWithRetry(ctx context.Context, anchor string, eids
219225
return true, nil
220226
}
221227

222-
return !isRetriableLockError(err), err
228+
return !isRetriableLockError(ctx, err), err
223229
})
224230
if err != nil {
225231
return errors.WithMessagef(err, "failed to acquire locks for anchor [%s]", anchor)
@@ -230,13 +236,26 @@ func (a *Service) acquireLocksWithRetry(ctx context.Context, anchor string, eids
230236

231237
// isRetriableLockError reports whether re-running AcquireLocks stands a chance of
232238
// a different outcome. Most failures do — a contended lock may be free by now, a
233-
// database blip may have passed — but two do not: ErrLockAcquireTimeout means the
234-
// locker already spent its whole waiting budget, so an identical attempt would
235-
// just spend it again, and a context error means the caller is no longer waiting.
236-
func isRetriableLockError(err error) bool {
239+
// database blip may have passed — but three do not: ErrLockAcquireTimeout means
240+
// the locker already spent its whole waiting budget, so an identical attempt would
241+
// just spend it again; ErrLockSetWidened is a caller error that every attempt will
242+
// reproduce; and a caller whose own context is done is no longer waiting for an
243+
// answer.
244+
//
245+
// Whether the caller is gone is read from ctx, not from the error. The lockers
246+
// bound their own waiting, and a budget of their own that elapses with nothing
247+
// contending surfaces as a bare context.DeadlineExceeded — indistinguishable, by
248+
// the error alone, from the caller's deadline elapsing. Classifying it from the
249+
// error stopped the auditor after a single attempt at exactly the transient
250+
// database failures this retry exists to survive, while ctx was still perfectly
251+
// live.
252+
func isRetriableLockError(ctx context.Context, err error) bool {
253+
if ctx.Err() != nil {
254+
return false
255+
}
256+
237257
return !errors.Is(err, auditdb.ErrLockAcquireTimeout) &&
238-
!errors.Is(err, context.Canceled) &&
239-
!errors.Is(err, context.DeadlineExceeded)
258+
!errors.Is(err, auditdb.ErrLockSetWidened)
240259
}
241260

242261
// Append adds the passed transaction to the auditor database, reusing the

token/services/auditor/auditor_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"time"
1515

1616
"github.com/LFDT-Panurus/panurus/token"
17+
commondrivermock "github.com/LFDT-Panurus/panurus/token/core/common/driver/mock"
18+
"github.com/LFDT-Panurus/panurus/token/core/common/metrics"
1719
drivermock "github.com/LFDT-Panurus/panurus/token/driver/mock"
1820
tokenmock "github.com/LFDT-Panurus/panurus/token/mock"
1921
"github.com/LFDT-Panurus/panurus/token/services/auditor"
@@ -1222,3 +1224,184 @@ func TestService_AcquireLocksWithRetry_EmptyEnrollmentIDs(t *testing.T) {
12221224
require.NoError(t, err)
12231225
assert.Equal(t, 1, mockLocker.GetCallCount())
12241226
}
1227+
1228+
// newTestServiceWithMockLockerAndMetrics is newTestServiceWithMockLocker with a
1229+
// metrics provider the caller can read back.
1230+
func newTestServiceWithMockLockerAndMetrics(
1231+
t *testing.T, mockLocker *mockAuditLocker, mp metrics.Provider,
1232+
) *auditor.Service {
1233+
t.Helper()
1234+
1235+
fakeStore := newFakeStore()
1236+
storeService, err := auditdb.NewStoreService(fakeStore, auditdb.WithLocker(mockLocker))
1237+
require.NoError(t, err)
1238+
1239+
return auditor.NewService(
1240+
token.TMSID{},
1241+
nil, // networkProvider
1242+
storeService,
1243+
nil, // tokenDB
1244+
nil, // tmsProvider
1245+
nil, // finalityTracer
1246+
mp,
1247+
nil, // checkService
1248+
nil, // lockConfig (uses defaults)
1249+
)
1250+
}
1251+
1252+
// countingCounter records the total added, so a test can assert on whether a
1253+
// metric was touched at all.
1254+
type countingCounter struct {
1255+
mu sync.Mutex
1256+
total float64
1257+
}
1258+
1259+
func (c *countingCounter) With(...string) metrics.Counter { return c }
1260+
1261+
func (c *countingCounter) Add(delta float64) {
1262+
c.mu.Lock()
1263+
defer c.mu.Unlock()
1264+
c.total += delta
1265+
}
1266+
1267+
func (c *countingCounter) Total() float64 {
1268+
c.mu.Lock()
1269+
defer c.mu.Unlock()
1270+
1271+
return c.total
1272+
}
1273+
1274+
// lockConflictProvider hands out countingCounter for the lock-conflict metric and
1275+
// discards everything else.
1276+
func lockConflictProvider() (metrics.Provider, *countingCounter) {
1277+
conflicts := &countingCounter{}
1278+
mp := &commondrivermock.MetricsProvider{}
1279+
mp.NewCounterStub = func(opts metrics.CounterOpts) metrics.Counter {
1280+
if opts.Name == "auditor_audit_lock_conflicts_total" {
1281+
return conflicts
1282+
}
1283+
1284+
return &countingCounter{}
1285+
}
1286+
mp.NewHistogramStub = func(metrics.HistogramOpts) metrics.Histogram { return discardHistogram{} }
1287+
mp.NewGaugeStub = func(metrics.GaugeOpts) metrics.Gauge { return discardGauge{} }
1288+
1289+
return mp, conflicts
1290+
}
1291+
1292+
type discardHistogram struct{}
1293+
1294+
func (discardHistogram) With(...string) metrics.Histogram { return discardHistogram{} }
1295+
func (discardHistogram) Observe(float64) {}
1296+
1297+
type discardGauge struct{}
1298+
1299+
func (discardGauge) With(...string) metrics.Gauge { return discardGauge{} }
1300+
func (discardGauge) Add(float64) {}
1301+
func (discardGauge) Set(float64) {}
1302+
1303+
// TestService_AcquireLocksWithRetry_RetriesTransientFailureWithLiveCaller covers
1304+
// the classification of a failure that carries a context error but did not come
1305+
// from the caller giving up. When a locker's own acquisition budget elapses with
1306+
// nothing contending, it returns a bare context.DeadlineExceeded and no sentinel —
1307+
// see the Postgres backend's default branch. Deciding from the error alone made
1308+
// that final, so the auditor stopped after one attempt at exactly the transient
1309+
// database failures this retry exists to survive, while its own context was still
1310+
// perfectly live. Whether the caller is gone is a property of ctx, not of the
1311+
// error.
1312+
func TestService_AcquireLocksWithRetry_RetriesTransientFailureWithLiveCaller(t *testing.T) {
1313+
attempts := 0
1314+
mockLocker := newMockAuditLocker(func(context.Context, string, ...string) error {
1315+
attempts++
1316+
if attempts < 3 {
1317+
return errors.Wrap(context.DeadlineExceeded, "acquire eid leases")
1318+
}
1319+
1320+
return nil
1321+
})
1322+
svc := newTestServiceWithMockLocker(t, mockLocker)
1323+
1324+
_, _, err := svc.Audit(context.Background(), &auditmock.Transaction{
1325+
IDStub: func() string { return "tx-lock-transient" },
1326+
RequestStub: func() *token.Request {
1327+
return token.NewRequest(newTestManagementService(t), token.RequestAnchor("tx-lock-transient"))
1328+
},
1329+
})
1330+
1331+
require.NoError(t, err)
1332+
assert.Equal(t, 3, mockLocker.GetCallCount(),
1333+
"a locker whose own budget elapsed must be retried while the caller is still waiting")
1334+
}
1335+
1336+
// TestService_AcquireLocksWithRetry_StopsWhenCallerIsGone is the other half: once
1337+
// the caller's context is done there is nobody left to hand the locks to, so the
1338+
// loop must stop regardless of what the error says.
1339+
func TestService_AcquireLocksWithRetry_StopsWhenCallerIsGone(t *testing.T) {
1340+
ctx, cancel := context.WithCancel(context.Background())
1341+
// Contention is the most retriable failure there is, so this pins the decision on
1342+
// the caller having gone rather than on the error. The cancellation happens
1343+
// inside the first attempt, so the retry loop does reach the locker once.
1344+
mockLocker := newMockAuditLocker(func(context.Context, string, ...string) error {
1345+
cancel()
1346+
1347+
return auditdb.ErrLockContention
1348+
})
1349+
svc := newTestServiceWithMockLocker(t, mockLocker)
1350+
1351+
_, _, err := svc.Audit(ctx, &auditmock.Transaction{
1352+
IDStub: func() string { return "tx-lock-caller-gone" },
1353+
RequestStub: func() *token.Request {
1354+
return token.NewRequest(newTestManagementService(t), token.RequestAnchor("tx-lock-caller-gone"))
1355+
},
1356+
})
1357+
1358+
require.Error(t, err)
1359+
assert.Equal(t, 1, mockLocker.GetCallCount(), "a cancelled caller must not be retried for")
1360+
}
1361+
1362+
// TestService_Audit_CountsOnlyRealLockConflicts pins the lock-conflict metric to
1363+
// what the error-classification table in docs/services/auditor.md promises: a
1364+
// failure with no second holder involved is "not a conflict, and not counted as
1365+
// one". The counter was incremented for every acquisition failure, so
1366+
// graceful-shutdown cancellations and database outages inflated the one signal
1367+
// operators are told to alert on for contention.
1368+
func TestService_Audit_CountsOnlyRealLockConflicts(t *testing.T) {
1369+
t.Run("contention is counted", func(t *testing.T) {
1370+
mp, conflicts := lockConflictProvider()
1371+
mockLocker := newMockAuditLocker(func(context.Context, string, ...string) error {
1372+
return errors.Join(auditdb.ErrLockContention, auditdb.ErrLockAcquireTimeout)
1373+
})
1374+
svc := newTestServiceWithMockLockerAndMetrics(t, mockLocker, mp)
1375+
1376+
_, _, err := svc.Audit(context.Background(), &auditmock.Transaction{
1377+
IDStub: func() string { return "tx-conflict" },
1378+
RequestStub: func() *token.Request {
1379+
return token.NewRequest(newTestManagementService(t), token.RequestAnchor("tx-conflict"))
1380+
},
1381+
})
1382+
1383+
require.Error(t, err)
1384+
assert.InDelta(t, 1, conflicts.Total(), 0)
1385+
})
1386+
1387+
t.Run("a cancelled caller is not counted", func(t *testing.T) {
1388+
mp, conflicts := lockConflictProvider()
1389+
mockLocker := newMockAuditLocker(func(ctx context.Context, _ string, _ ...string) error {
1390+
return ctx.Err()
1391+
})
1392+
svc := newTestServiceWithMockLockerAndMetrics(t, mockLocker, mp)
1393+
1394+
ctx, cancel := context.WithCancel(context.Background())
1395+
cancel()
1396+
_, _, err := svc.Audit(ctx, &auditmock.Transaction{
1397+
IDStub: func() string { return "tx-cancelled" },
1398+
RequestStub: func() *token.Request {
1399+
return token.NewRequest(newTestManagementService(t), token.RequestAnchor("tx-cancelled"))
1400+
},
1401+
})
1402+
1403+
require.Error(t, err)
1404+
assert.Zero(t, conflicts.Total(),
1405+
"nothing held the enrollment IDs, so the failure is not a lock conflict")
1406+
})
1407+
}

token/services/auditor/metrics.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ type Metrics struct {
1616
// invocation (lock acquisition included), in seconds.
1717
AuditDuration metrics.Histogram
1818

19-
// AuditLockConflicts counts calls to Audit() that failed because
20-
// AcquireLocks returned an error (e.g. contention or timeout).
19+
// AuditLockConflicts counts calls to Audit() that failed because another
20+
// anchor held one of the enrollment IDs, i.e. those whose error carries
21+
// ErrLockContention. Failures with no second holder involved — a cancelled
22+
// caller, a database outage — are not conflicts and are deliberately not
23+
// counted here, so that alerting on this metric measures contention only.
2124
AuditLockConflicts metrics.Counter
2225

2326
// AppendDuration is a histogram of the total wall-clock time for each

token/services/storage/auditdb/locker.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ var (
2929
ErrLockAcquireTimeout = locker.ErrLockAcquireTimeout
3030
ErrLockLost = locker.ErrLockLost
3131
ErrLockNotHeld = locker.ErrLockNotHeld
32+
ErrLockSetWidened = locker.ErrLockSetWidened
3233
ErrLockerOwnerRequired = locker.ErrLockerOwnerRequired
3334
)
3435

token/services/storage/auditdb/locker/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package locker
99
import (
1010
"time"
1111

12+
"github.com/LFDT-Panurus/panurus/token/services/storage/auditdb/locker/memory"
1213
lockerpostgres "github.com/LFDT-Panurus/panurus/token/services/storage/auditdb/locker/postgres"
1314
)
1415

@@ -24,6 +25,7 @@ const (
2425
// It is read from the TMS configuration under the key "auditor.locker".
2526
type Config struct {
2627
Backend Backend `yaml:"backend"`
28+
Memory memory.Config `yaml:"memory"`
2729
Postgres lockerpostgres.Config `yaml:"postgres"`
2830
}
2931

@@ -32,6 +34,9 @@ type Config struct {
3234
func DefaultConfig() Config {
3335
return Config{
3436
Backend: BackendMemory,
37+
Memory: memory.Config{
38+
AcquireDeadline: memory.DefaultAcquireDeadline,
39+
},
3540
Postgres: lockerpostgres.Config{
3641
TTL: 30 * time.Second,
3742
AcquireBackoff: 100 * time.Millisecond,

0 commit comments

Comments
 (0)