Skip to content

fix(selector): close sherdlock lock-contention gaps (Phases 3-8, #2395) - #2410

Open
adecaro wants to merge 5 commits into
mainfrom
fix/2395-consolidated-3-8
Open

adecaro wants to merge 5 commits into
mainfrom
fix/2395-consolidated-3-8

Conversation

@adecaro

@adecaro adecaro commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the sherdlock token selector's lock-contention hot-spot that caused spurious "insufficient funds" errors under concurrent load (#2395).

Six mechanisms made a small number of "hot" tokens absorb the vast majority of lock collisions. This PR closes all of them:

  1. Anti-join — the DB query that feeds the selector now excludes already-locked tokens, so selectors stop queuing up to fight over the same row.
  2. Amount-ordered candidates + shuffle — tokens are fetched smallest-first so a small payment doesn't wastefully grab a large token; equal-amount candidates are shuffled so contention doesn't just shift to whichever token happens to sort first.
  3. Sufficiency-window randomization — when the smallest sufficient token is found, a bounded lookahead picks uniformly among similarly-sized candidates, preventing all concurrent selectors from deterministically targeting the exact same token.
  4. Blacklisting — a token that lost a lock race is skipped for the rest of that Select() call, instead of being retried in a tight loop for minutes.
  5. Immediate lock release on settlement — when a transaction reaches a terminal status (confirmed, deleted, or retry-exhausted), its locks are released immediately via the finality listener and recovery handler, rather than sitting until the 3-minute lease-expiry sweep during which those tokens remain invisible to all other selectors.
  6. Postgres lock strategies — two new acquisition modes (onConflict, skipLocked) that avoid server-side unique-constraint violations on lost races, plus batch locking (LockBatch) that claims a covering window of candidates in a single round-trip.

Consolidates the #2398-#2403 stack (Phases 3-8 of #2395) into a single PR on top of #2397's diagnostics/baseline, so the remaining lock-contention work reviews as one unit instead of five stacked PRs. Supersedes #2398, #2399, #2400, #2402, #2403.

Follow-up: two review-flagged gaps closed

  • Selection was still deterministic under realistic, distinct token amounts. The Phase 4 bucketedIterator/NewPermutation shuffle only randomizes within contiguous runs of byte-equal Quantity; with mostly-distinct amounts (the CERT-incident shape) every bucket is size 1, so every concurrent selector always targeted the single smallest sufficient token — reproducing the exact hot-token pattern the issue warned against. Fixed in the selector layer (sherdlock/selector.go): when the ascending candidate scan reaches a token that alone covers the remaining requested amount, it now looks ahead over a small bounded window (count-capped by sufficiencyWindow, magnitude-capped by maxSufficiencyRatio so it can't grab a wildly oversized token) and picks uniformly at random among the sufficient candidates in that window. New test TestSizeOrderedSelection_SufficiencyWindowShuffle proves real spread among several distinct, individually-sufficient amounts while the existing deterministic smallest-fit test still holds.
  • Listener.OnError never released locks, unlike OnStatus/recovery — a transaction whose finality notification is permanently undeliverable (retry budget exhausted) kept its locks held until the next lease-expiry sweep, reproducing Phase 5's original gap via a different trigger. OnError now calls the same releaseLocks helper as runOnStatus; new tests TestOnError_ReleasesLocks and TestOnError_LockReleaseErrorDoesNotPropagate cover it.

Follow-up: independent review, 6 fixes (TDD, each with its own commit)

  • Listener.runOnStatus's default: branch released locks on non-terminal network.Busy/Unknown statuses — these reach OnStatus in normal operation, not just error paths; releasing locks mid-commit let a concurrent Select hand the same tokens to a second transaction. Fixed by adding explicit case network.Busy, network.Unknown: that returns nil without touching locks or stores. New tests: TestOnStatus_DoesNotReleaseLocksOnNonTerminalStatus, TestOnStatus_ReleasesLocksOnceOnRetryExhaustion, TestOnStatus_ReleasesLocksExactlyOnceOnUnrecognizedStatus.
  • HasEnoughSpendableTokens fast-fail double-counted tokens the current call had already locked — the comparison was against remaining (quantity minus what this call already selected), but the lock-ignoring total already includes the already-selected tokens, so the check could never fire once anything had been won. Fixed by comparing against the full quantity. New test: TestSelectorFastFail_PartiallyFilledRequest.
  • The sufficiency-window shuffle was inert whenever the smallest sufficient token was already >5x the requested amount — the magnitude cap was computed from remaining, not from the anchor token itself, so the window collapsed to size 1 exactly in the regime it was built to fix. Fixed by anchoring the cap on the anchor token's own quantity. New test: TestSizeOrderedSelection_SufficiencyWindowWhenEveryTokenDwarfsTheRequest.
  • A batch-lock store error silently dropped the whole candidate window — contradicted its own comment by never requeuing the window tokens, costing a full refetch cycle and risking a transient store outage being misreported as lock contention. Fixed by refetching (bounded by the existing maxImmediateRetries budget). New tests: TestBatchLockStoreError_WindowIsRefetchedNotDropped, TestBatchLockStoreError_TerminatesWithinRetryBudget.
  • HasAnySpendableTokens was dead code — added to the public driver.TokenStore interface in Phase 4a, superseded by HasEnoughSpendableTokens in Phase 6, with zero remaining production callers. Removed from the interface, all implementations, and mocks.
  • Pre-existing lint breakage in benchmark_test.go — 4 ireturn + 1 thelper issue that would have failed CI. Fixed mechanically.

Test plan

All items verified locally against the rebased branch (Go 1.27.1, Docker 29.8.1, Fabric v3.1.1 binaries).

  • go build ./... — plus the nested x/token/services/network/evm module, which ./... from the root does not reach

  • go test -race ./token/services/selector/... ./token/services/ttx/... ./token/services/storage/... — 0 failures, no data races, including the Docker-backed TestHotTokenContention*/TestStaticHotTokenContentionPareto suites, the ordering/OnError tests, and the review-fix tests above

  • make checks — both halves green: checks-fast (license, gofmt, goimports, misspell, ineffassign, protos-lint, buf-format, ✓ All Go modules are tidy) and checks-heavy (go vet, go fix, staticcheck, govulncheck — the 2 reachable vulnerabilities are the ones already in govulncheck-allowlist.txt, treated as a pass as on main). This needed the staticcheck v0.7.0 → v0.8.1 bump in this PR: v0.7.0 panics in its own IR builder under a Go 1.27.1 module.

  • make unit-tests-race (full suite) — every package green, with no data races and no panics. It takes two runs to show this, because two pre-existing tests have contradictory path requirements and neither can be satisfied at once outside CI: TestTranslatePath (token/services/identity/config) asserts the translated path contains "panurus", while TestTMSScopedProviderWiringIsIntact (token/services/metricsdoc) greps the repo with filepath.WalkDir, which does not follow symlinks. Run from the worktree path, the only failure is the former; run through a …/panurus symlink, the only failure is the latter; each passes in the other path, so the union covers every package. CI hits neither, since its checkout is a real directory named panurus.

  • Integration tests (fabtoken/dlog, TEST_FILTER="T1") — both suites pass against this branch:

    • make integration-tests-fabtoken-fabric-t1 → Ran 3 of 15 Specs — 3 Passed, 0 Failed, 12 skipped (33m55s)
    • make integration-tests-dlog-fabric-t1 → Ran 3 of 44 Specs — 3 Passed, 0 Failed, 41 skipped (35m18s)

    The 3 specs per suite are the T1 label across all three infra types (websocket, libp2p, replicas). The transaction … is not valid [Deleted] errors in the dlog log are asserted by the suite itself (integration/token/fungible/tests.go:550 passes "is not valid" as an expected message), not failures.

Fixes #2395

🤖 Generated with Claude Code

@AkramBitar

AkramBitar commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The PR fixes a bug where a small number of tokens were getting "stuck" under concurrent load — many selectors would pile up racing to lock the same few tokens, causing spurious "insufficient funds" errors.

Root causes fixed:

  • Selectors could see and fight over tokens already locked by someone else
  • All selectors always picked the same smallest token (deterministic ordering)
  • Lost lock races were retried in a tight loop on the same token for minutes
  • Settled transactions never released their locks, keeping tokens invisible to others

How:

  • DB query now hides locked tokens from the selector entirely
  • Candidates are shuffled within similarly-sized groups so concurrent selectors spread out
  • Tokens that lost a race are blacklisted for the rest of that call
  • Locks are released immediately on settlement, not after a 3-minute sweep
  • New Postgres lock modes that avoid server-side constraint violation errors
  • On Postgres, a whole window of candidate tokens is locked in one round trip instead of one at a time

One thing to know when monitoring: on Postgres the batch lock only reports which tokens it won, so a token that was already spent looks the same as one that was simply locked by someone else. It is counted as a lock conflict, which means stale_candidates_total stays at zero on that backend.

Comment thread token/services/metricsdoc/testdata/metrics.golden
Comment thread token/services/selector/sherdlock/selector.go Outdated
Comment thread token/services/selector/sherdlock/selector.go
Comment thread token/services/storage/db/sql/common/tokenlock.go Outdated
Comment thread token/services/selector/sherdlock/selector.go
@AkramBitar
AkramBitar force-pushed the fix/2395-consolidated-3-8 branch from 661291a to 583e600 Compare October 2, 2026 11:18
@AkramBitar
AkramBitar requested a review from Effi-S October 2, 2026 11:23

@Effi-S Effi-S left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only Minor / Nit findings:

Also to consider:

  • No index on amount (tokens.go).
    New ORDER BY amount on every spendable-tokens query sorts without a supporting index. Cached fetcher masks it; could matter for large wallets / lazy fetcher. Consider an index if this query is hot.

Comment thread token/services/selector/simple/selector.go
Comment thread token/services/storage/db/driver/token.go
Comment thread token/services/selector/sherdlock/fetcher.go
@Effi-S
Effi-S force-pushed the fix/2395-consolidated-3-8 branch from 583e600 to db0cb20 Compare October 4, 2026 15:34
AkramBitar added a commit that referenced this pull request Oct 5, 2026
The sherdlock token selector made a small number of "hot" tokens absorb the vast
majority of lock collisions, surfacing as spurious "insufficient funds" errors
under concurrent load (#2395). Six mechanisms contributed; this closes all of
them, consolidating the #2398-#2403 stack (Phases 3-8) on top of #2397's
diagnostics and baseline. Supersedes #2398, #2399, #2400, #2402, #2403.

1. Anti-join: the candidate query excludes already-locked tokens, so selectors
   stop queueing up to fight over the same row.
2. Amount-ordered candidates plus shuffle: tokens are fetched smallest-first so
   a small payment does not grab a large token, and equal-amount candidates are
   shuffled so contention does not simply shift onto whichever token sorts
   first.
3. Sufficiency-window randomization: once the ascending scan reaches a token
   that alone covers the remaining amount, a bounded lookahead picks uniformly
   among similarly-sized candidates - count-capped by sufficiencyWindow and
   magnitude-capped by maxSufficiencyRatio, anchored on the anchor token itself
   so the window does not collapse to size 1 when every candidate dwarfs the
   request.
4. Blacklisting: a token that lost a lock race is skipped for the rest of that
   Select call instead of being retried in a tight loop for minutes.
5. Immediate lock release on settlement: a transaction reaching a terminal
   status releases its locks through the finality listener and the recovery
   handler, rather than holding them until the lease-expiry sweep. Busy and
   Unknown are explicitly non-terminal and leave locks alone; OnError and a
   retry-exhausted OnStatus release exactly once.
6. Postgres lock strategies: onConflict and skipLocked avoid server-side
   unique-constraint violations on lost races, and LockBatch claims a covering
   window of candidates in one round trip.

Review follow-ups folded in: the fast-fail balance check compares against the
full requested quantity rather than the remaining amount, which it had been
double-counting; a batch-lock store error refetches the window instead of
silently dropping it, bounded by the existing retry budget; the EVM recovery
handler releases selection locks like its Fabric counterpart; the now-unused
HasAnySpendableTokens is removed from the driver interface and its
implementations; and benchmark_test.go's pre-existing ireturn/thelper lint
breakage is fixed.

Two accuracy fixes from the last review round: StaleCandidates is incremented on
the single-token lock path only - LockBatch reports just the tokens it won, so a
stale candidate is indistinguishable there from a lost race and is booked as
LockConflicts - which the counter's own documentation and the metrics page had
claimed otherwise, and the batch branch now records the three consequences that
follow from it. maxSufficiencyRatio also gains deterministic coverage: in both
sufficiency-window tests the count cap binds first, so the ratio bound could be
disabled without either of them noticing.

Third review round (review 5404492139 on #2410): the simple driver's bounded-pool
deadlock is now fixed rather than documented as a known limitation. selectByID
held its unspentTokens cursor open across the nested concurrencyCheck query, so
every in-flight Select pinned two connections and a pool smaller than the
concurrent-selector count deadlocked outright - each connection handed to an open
cursor, each goroutine blocked waiting for a second one. The candidate scan is
already finished with the cursor by then and a retry opens a fresh one, so it is
closed before the re-check and one selection needs one connection.
TestSimpleDriverBoundedPool pins it with 16 concurrent selectors against a pool of
2, and stalls if the overlapping checkout is restored. The same round adds
idx_spendable_amount on (owner_wallet_id, token_type, amount), partial on the
spendable-tokens query's own is_deleted/owner/spendable predicates, so its ORDER BY
amount reads rows already ordered instead of sorting the wallet - a new index name
rather than an extra column on idx_owner_wallet_part, since CREATE INDEX IF NOT
EXISTS would not replace an index already deployed under that name. Plus the
documented assumption behind bucketedIterator's bucket boundaries: they use string
equality on the stored quantity, which identifies equal amounts only because that
encoding is canonical while the ORDER BY is numeric, so a non-canonical encoding
would degrade the shuffle to a no-op rather than produce a wrong order.
IsTerminalStatus is left exported - cmd/tokendiag/cobra/locks/runner.go is a
production caller, so it is not test-only.

Tests: hot-token contention suites reproducing the CERT incident's Pareto shape
and its static-hot-token variant, a simple-driver baseline, lock-outcome
classification across both the single-token and batch paths, stale-candidate
handling on both, the sufficiency-window and ratio-boundary ordering tests, and
finality listener/recovery coverage for every status transition that touches
locks.

build(tools): bump staticcheck to v0.8.1 so make checks runs under Go 1.27

staticcheck v0.7.0 panics in its own IR builder (unexpected expr:
*ast.KeyValueExpr) against this module's Go 1.27.1 toolchain, on packages
unrelated to this change, which makes the checks-heavy stage of make checks
unrunnable locally. v0.8.1 (2026.2.1) analyses the same tree cleanly with no new
findings. Separable from the selector fix if a maintainer prefers it on its own.

Signed-off-by: AkramBitar <akram@il.ibm.com>
@AkramBitar
AkramBitar force-pushed the fix/2395-consolidated-3-8 branch from db0cb20 to 9b8ecd3 Compare October 5, 2026 11:28
@AkramBitar

AkramBitar commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

All four addressed in 9b8ecd3 (branch squashed to one commit).

1. simple bounded-pool deadlock — fixed, not just confirmed. The overlap turned out to be unnecessary: the scan loop is already done with the cursor by the time concurrencyCheck runs, and a retry opens a fresh one. selectByID now closes it before the re-check, so one in-flight Select holds one connection instead of two. TestSimpleDriverBoundedPool (16 selectors, MaxOpenConns=2) stalls if the fix is reverted.

2. IsTerminalStatus test-only — I don't think this holds. cmd/tokendiag/cobra/locks/runner.go:64 is a production caller, so I left it exported. Say the word if you meant a different symbol.

3. bucketedIterator bucketing — comment added on NewPermutation: string equality identifies equal amounts only because the quantity encoding is canonical, while the ORDER BY is numeric. A non-canonical encoding would degrade the shuffle to a no-op rather than break the ordering.

4. amount index — added. idx_spendable_amount on (owner_wallet_id, token_type, amount), partial on the spendable query's own predicates. New index name rather than an extra column on idx_owner_wallet_part, since CREATE INDEX IF NOT EXISTS won't replace an index already deployed under that name.

make checks and make lint clean; selector, sql/common, sql/sqlite and the Postgres token suites pass on the current base. Docs updated in docs/services/selector.md.

@AkramBitar
AkramBitar requested a review from Effi-S October 5, 2026 11:32

@Effi-S Effi-S left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AkramBitar,
Some Low findings:

  1. common/tokens.go:GetSchema and common/tokenlock.go:GetSchema both CREATE TABLE IF NOT EXISTS
    the TokenLocks table. Definitions are currently identical, but IF NOT EXISTS means if they ever
    drift, whichever runs first silently wins and the other is a no-op
  2. Postgres is the sole BatchLocker, and the batch path can't distinguish stale from lost-race, so it
    books everything as LockConflicts and stale_candidates_total stays 0 in production.
  3. !hasEnough now returns terminal SelectorInsufficientFunds with no retry. On a lagging read replica
    a wallet whose just-added tokens aren't visible yet fast-fails instead of retrying. Not a regression..
    SpendableTokensIteratorBy reads the same replica, so the old path would also have failed (afterburning retries).. but the retry cushion is gone.

AkramBitar added a commit that referenced this pull request Oct 5, 2026
The sherdlock token selector made a small number of "hot" tokens absorb the vast
majority of lock collisions, surfacing as spurious "insufficient funds" errors
under concurrent load (#2395). Six mechanisms contributed; this closes all of
them, consolidating the #2398-#2403 stack (Phases 3-8) on top of #2397's
diagnostics and baseline. Supersedes #2398, #2399, #2400, #2402, #2403.

1. Anti-join: the candidate query excludes already-locked tokens, so selectors
   stop queueing up to fight over the same row.
2. Amount-ordered candidates plus shuffle: tokens are fetched smallest-first so
   a small payment does not grab a large token, and equal-amount candidates are
   shuffled so contention does not simply shift onto whichever token sorts
   first.
3. Sufficiency-window randomization: once the ascending scan reaches a token
   that alone covers the remaining amount, a bounded lookahead picks uniformly
   among similarly-sized candidates - count-capped by sufficiencyWindow and
   magnitude-capped by maxSufficiencyRatio, anchored on the anchor token itself
   so the window does not collapse to size 1 when every candidate dwarfs the
   request.
4. Blacklisting: a token that lost a lock race is skipped for the rest of that
   Select call instead of being retried in a tight loop for minutes.
5. Immediate lock release on settlement: a transaction reaching a terminal
   status releases its locks through the finality listener and the recovery
   handler, rather than holding them until the lease-expiry sweep. Busy and
   Unknown are explicitly non-terminal and leave locks alone; OnError and a
   retry-exhausted OnStatus release exactly once.
6. Postgres lock strategies: onConflict and skipLocked avoid server-side
   unique-constraint violations on lost races, and LockBatch claims a covering
   window of candidates in one round trip.

Review follow-ups folded in: the fast-fail balance check compares against the
full requested quantity rather than the remaining amount, which it had been
double-counting; a batch-lock store error refetches the window instead of
silently dropping it, bounded by the existing retry budget; the EVM recovery
handler releases selection locks like its Fabric counterpart; the now-unused
HasAnySpendableTokens is removed from the driver interface and its
implementations; and benchmark_test.go's pre-existing ireturn/thelper lint
breakage is fixed.

Two accuracy fixes from the last review round: StaleCandidates is incremented on
the single-token lock path only - LockBatch reports just the tokens it won, so a
stale candidate is indistinguishable there from a lost race and is booked as
LockConflicts - which the counter's own documentation and the metrics page had
claimed otherwise, and the batch branch now records the three consequences that
follow from it. maxSufficiencyRatio also gains deterministic coverage: in both
sufficiency-window tests the count cap binds first, so the ratio bound could be
disabled without either of them noticing.

Third review round (review 5404492139 on #2410): the simple driver's bounded-pool
deadlock is now fixed rather than documented as a known limitation. selectByID
held its unspentTokens cursor open across the nested concurrencyCheck query, so
every in-flight Select pinned two connections and a pool smaller than the
concurrent-selector count deadlocked outright - each connection handed to an open
cursor, each goroutine blocked waiting for a second one. The candidate scan is
already finished with the cursor by then and a retry opens a fresh one, so it is
closed before the re-check and one selection needs one connection.
TestSimpleDriverBoundedPool pins it with 16 concurrent selectors against a pool of
2, and stalls if the overlapping checkout is restored. The same round adds
idx_spendable_amount on (owner_wallet_id, token_type, amount), partial on the
spendable-tokens query's own is_deleted/owner/spendable predicates, so its ORDER BY
amount reads rows already ordered instead of sorting the wallet - a new index name
rather than an extra column on idx_owner_wallet_part, since CREATE INDEX IF NOT
EXISTS would not replace an index already deployed under that name. Plus the
documented assumption behind bucketedIterator's bucket boundaries: they use string
equality on the stored quantity, which identifies equal amounts only because that
encoding is canonical while the ORDER BY is numeric, so a non-canonical encoding
would degrade the shuffle to a no-op rather than produce a wrong order.
IsTerminalStatus is left exported - cmd/tokendiag/cobra/locks/runner.go is a
production caller, so it is not test-only.

Tests: hot-token contention suites reproducing the CERT incident's Pareto shape
and its static-hot-token variant, a simple-driver baseline, lock-outcome
classification across both the single-token and batch paths, stale-candidate
handling on both, the sufficiency-window and ratio-boundary ordering tests, and
finality listener/recovery coverage for every status transition that touches
locks.

build(tools): bump staticcheck to v0.8.1 so make checks runs under Go 1.27

staticcheck v0.7.0 panics in its own IR builder (unexpected expr:
*ast.KeyValueExpr) against this module's Go 1.27.1 toolchain, on packages
unrelated to this change, which makes the checks-heavy stage of make checks
unrunnable locally. v0.8.1 (2026.2.1) analyses the same tree cleanly with no new
findings. Separable from the selector fix if a maintainer prefers it on its own.

Fourth review round (review 5414277618 on #2410): three Low findings.

The TokenLocks DDL was emitted twice, by common.TokenLockStore.GetSchema and
common.TokenStore.GetSchema. Both must emit it - the locker owns the table and
the token store's notLocked anti-join depends on it - but every statement is
CREATE ... IF NOT EXISTS, so whichever store initializes first wins and a future
drift between the two copies would resolve silently. They now share one
tokenLocksSchema builder, so a change reaches both or neither.

stale_candidates_total read zero on exactly the deployments that matter. Postgres
is the only BatchLocker, and its claim statement answered with just the tokens it
won, which made a stale candidate indistinguishable from a lost race: the drop was
booked as LockConflicts, tokensLockedByOthersExist was set although nobody held
the token, and the candidate cache was never told it was behind the store. That is
now fixed rather than documented, as the previous round's test comment said it
should be. LockBatch returns driver.BatchLockOutcome{Won, Stale} and the claim
classifies every candidate in the same round trip, so the batch path recovers
within the call exactly as the single-token path does. Under skipLocked the
spendability predicate is evaluated a second time without the row lock, because a
row FOR UPDATE SKIP LOCKED walks past is contended and must not be reported stale;
that split is mutation-tested. The single-token Lock path also drops its follow-up
isSpendable probe - the claim now reports the state it actually saw, one round trip
lighter on that failure path - and a compile-time assertion pins the one production
BatchLocker, a capability discovered by type assertion and so able to disappear
silently, as benchBatchLocker promptly did.

The third finding, that the !hasEnough fast-fail leaves no retry cushion on a
lagging read replica, needs no change: before this PR an empty scan with no
observed lock conflict returned SelectorInsufficientFunds from that branch
unconditionally, and that error exits StubbornSelector's backoff loop outright, so
lag failed such a call then too. The check only ever turns a give-up into a retry.
Closing the lag window itself is a read-routing question - the candidate scan reads
the same replica. The reasoning is recorded in the code and in docs/services/selector.md
so it is not re-derived.

Tests: the batch stale-candidate test flipped to the recovered behaviour it was
written to predict, a new one pinning the degraded no-classification backend, and
a real-Postgres classification test covering won/stale/contended in one claim
under both batch strategies.
Signed-off-by: AkramBitar <akram@il.ibm.com>
@AkramBitar
AkramBitar force-pushed the fix/2395-consolidated-3-8 branch from 9b8ecd3 to b7c9890 Compare October 5, 2026 13:26
@AkramBitar

AkramBitar commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Thanks @Effi-S — all three in b7c9890, squashed to one commit.

1. Duplicate TokenLocks DDL. Fixed. Both stores have to emit the table, so they now share one builder (common.tokenLocksSchema): a change reaches both or neither.

2. stale_candidates_total stays 0. Fixed rather than documented. LockBatch returns driver.BatchLockOutcome{Won, Stale} and the claim classifies every candidate in the same round trip, so the batch path counts stale drops, keeps them out of tokensLockedByOthersExist, and refreshes the candidate cache — the same recovery the single-token path had.

One thing worth your eye: under skipLocked the spendability predicate is evaluated a second time without the row lock, because a row FOR UPDATE SKIP LOCKED walks past is contended, not stale. Mutation-tested — classifying off the claim's source fails the skipLocked test on exactly that assertion. A backend that cannot classify may still leave Stale empty and keeps today's behaviour.

3. !hasEnough on a lagging replica. No change, and I think the premise does not hold — happy to be wrong. On origin/main that branch already returned terminal SelectorInsufficientFunds unconditionally, and that error exits StubbornSelector's backoff loop, so lag failed such a call before this PR too. The check only ever turns a give-up into a retry. Reasoning recorded at the call site and in docs/services/selector.md.

New real-Postgres test covers won/stale/contended in one claim under both batch strategies; make checks and make lint clean.

AkramBitar added a commit that referenced this pull request Oct 6, 2026
The sherdlock token selector made a small number of "hot" tokens absorb the vast
majority of lock collisions, surfacing as spurious "insufficient funds" errors
under concurrent load (#2395). Six mechanisms contributed; this closes all of
them, consolidating the #2398-#2403 stack (Phases 3-8) on top of #2397's
diagnostics and baseline. Supersedes #2398, #2399, #2400, #2402, #2403.

1. Anti-join: the candidate query excludes already-locked tokens, so selectors
   stop queueing up to fight over the same row.
2. Amount-ordered candidates plus shuffle: tokens are fetched smallest-first so
   a small payment does not grab a large token, and equal-amount candidates are
   shuffled so contention does not simply shift onto whichever token sorts
   first.
3. Sufficiency-window randomization: once the ascending scan reaches a token
   that alone covers the remaining amount, a bounded lookahead picks uniformly
   among similarly-sized candidates - count-capped by sufficiencyWindow and
   magnitude-capped by maxSufficiencyRatio, anchored on the anchor token itself
   so the window does not collapse to size 1 when every candidate dwarfs the
   request.
4. Blacklisting: a token that lost a lock race is skipped for the rest of that
   Select call instead of being retried in a tight loop for minutes.
5. Immediate lock release on settlement: a transaction reaching a terminal
   status releases its locks through the finality listener and the recovery
   handler, rather than holding them until the lease-expiry sweep. Busy and
   Unknown are explicitly non-terminal and leave locks alone; OnError and a
   retry-exhausted OnStatus release exactly once.
6. Postgres lock strategies: onConflict and skipLocked avoid server-side
   unique-constraint violations on lost races, and LockBatch claims a covering
   window of candidates in one round trip.

Review follow-ups folded in: the fast-fail balance check compares against the
full requested quantity rather than the remaining amount, which it had been
double-counting; a batch-lock store error refetches the window instead of
silently dropping it, bounded by the existing retry budget; the EVM recovery
handler releases selection locks like its Fabric counterpart; the now-unused
HasAnySpendableTokens is removed from the driver interface and its
implementations; and benchmark_test.go's pre-existing ireturn/thelper lint
breakage is fixed.

Two accuracy fixes from the last review round: StaleCandidates is incremented on
the single-token lock path only - LockBatch reports just the tokens it won, so a
stale candidate is indistinguishable there from a lost race and is booked as
LockConflicts - which the counter's own documentation and the metrics page had
claimed otherwise, and the batch branch now records the three consequences that
follow from it. maxSufficiencyRatio also gains deterministic coverage: in both
sufficiency-window tests the count cap binds first, so the ratio bound could be
disabled without either of them noticing.

Third review round (review 5404492139 on #2410): the simple driver's bounded-pool
deadlock is now fixed rather than documented as a known limitation. selectByID
held its unspentTokens cursor open across the nested concurrencyCheck query, so
every in-flight Select pinned two connections and a pool smaller than the
concurrent-selector count deadlocked outright - each connection handed to an open
cursor, each goroutine blocked waiting for a second one. The candidate scan is
already finished with the cursor by then and a retry opens a fresh one, so it is
closed before the re-check and one selection needs one connection.
TestSimpleDriverBoundedPool pins it with 16 concurrent selectors against a pool of
2, and stalls if the overlapping checkout is restored. The same round adds
idx_spendable_amount on (owner_wallet_id, token_type, amount), partial on the
spendable-tokens query's own is_deleted/owner/spendable predicates, so its ORDER BY
amount reads rows already ordered instead of sorting the wallet - a new index name
rather than an extra column on idx_owner_wallet_part, since CREATE INDEX IF NOT
EXISTS would not replace an index already deployed under that name. Plus the
documented assumption behind bucketedIterator's bucket boundaries: they use string
equality on the stored quantity, which identifies equal amounts only because that
encoding is canonical while the ORDER BY is numeric, so a non-canonical encoding
would degrade the shuffle to a no-op rather than produce a wrong order.
IsTerminalStatus is left exported - cmd/tokendiag/cobra/locks/runner.go is a
production caller, so it is not test-only.

Tests: hot-token contention suites reproducing the CERT incident's Pareto shape
and its static-hot-token variant, a simple-driver baseline, lock-outcome
classification across both the single-token and batch paths, stale-candidate
handling on both, the sufficiency-window and ratio-boundary ordering tests, and
finality listener/recovery coverage for every status transition that touches
locks.

build(tools): bump staticcheck to v0.8.1 so make checks runs under Go 1.27

staticcheck v0.7.0 panics in its own IR builder (unexpected expr:
*ast.KeyValueExpr) against this module's Go 1.27.1 toolchain, on packages
unrelated to this change, which makes the checks-heavy stage of make checks
unrunnable locally. v0.8.1 (2026.2.1) analyses the same tree cleanly with no new
findings. Separable from the selector fix if a maintainer prefers it on its own.

Fourth review round (review 5414277618 on #2410): three Low findings.

The TokenLocks DDL was emitted twice, by common.TokenLockStore.GetSchema and
common.TokenStore.GetSchema. Both must emit it - the locker owns the table and
the token store's notLocked anti-join depends on it - but every statement is
CREATE ... IF NOT EXISTS, so whichever store initializes first wins and a future
drift between the two copies would resolve silently. They now share one
tokenLocksSchema builder, so a change reaches both or neither.

stale_candidates_total read zero on exactly the deployments that matter. Postgres
is the only BatchLocker, and its claim statement answered with just the tokens it
won, which made a stale candidate indistinguishable from a lost race: the drop was
booked as LockConflicts, tokensLockedByOthersExist was set although nobody held
the token, and the candidate cache was never told it was behind the store. That is
now fixed rather than documented, as the previous round's test comment said it
should be. LockBatch returns driver.BatchLockOutcome{Won, Stale} and the claim
classifies every candidate in the same round trip, so the batch path recovers
within the call exactly as the single-token path does. Under skipLocked the
spendability predicate is evaluated a second time without the row lock, because a
row FOR UPDATE SKIP LOCKED walks past is contended and must not be reported stale;
that split is mutation-tested. The single-token Lock path also drops its follow-up
isSpendable probe - the claim now reports the state it actually saw, one round trip
lighter on that failure path - and a compile-time assertion pins the one production
BatchLocker, a capability discovered by type assertion and so able to disappear
silently, as benchBatchLocker promptly did.

The third finding, that the !hasEnough fast-fail leaves no retry cushion on a
lagging read replica, needs no change: before this PR an empty scan with no
observed lock conflict returned SelectorInsufficientFunds from that branch
unconditionally, and that error exits StubbornSelector's backoff loop outright, so
lag failed such a call then too. The check only ever turns a give-up into a retry.
Closing the lag window itself is a read-routing question - the candidate scan reads
the same replica. The reasoning is recorded in the code and in docs/services/selector.md
so it is not re-derived.

Tests: the batch stale-candidate test flipped to the recovered behaviour it was
written to predict, a new one pinning the degraded no-classification backend, and
a real-Postgres classification test covering won/stale/contended in one claim
under both batch strategies.

Fifth review round (on #2410): two Low findings, both comment-only. The
sufficiency-window lookahead buffer is documented as bounded by
sufficiencyWindow rather than by the wallet - nextCandidate dequeues from the
buffer before it touches the cache, so a window is assembled out of the buffer
first and only sufficiencyWindow-1 entries are ever put back, including in a
wallet where every token is individually sufficient. And claimCandidates records
that its three spendability-flag placeholders appear twice in the query under
skipLocked on purpose: a Postgres $N may be referenced any number of times for a
single positional argument, so the reuse must not be mirrored by a second append
to args, and keeping them literally the same placeholders is what makes the two
CTEs provably the same predicate.

Signed-off-by: AkramBitar <akram@il.ibm.com>
@AkramBitar
AkramBitar force-pushed the fix/2395-consolidated-3-8 branch from b7c9890 to b46c93a Compare October 6, 2026 13:31
AkramBitar added a commit that referenced this pull request Oct 6, 2026
The sherdlock token selector made a small number of "hot" tokens absorb the vast
majority of lock collisions, surfacing as spurious "insufficient funds" errors
under concurrent load (#2395). Six mechanisms contributed; this closes all of
them, consolidating the #2398-#2403 stack (Phases 3-8) on top of #2397's
diagnostics and baseline. Supersedes #2398, #2399, #2400, #2402, #2403.

1. Anti-join: the candidate query excludes already-locked tokens, so selectors
   stop queueing up to fight over the same row.
2. Amount-ordered candidates plus shuffle: tokens are fetched smallest-first so
   a small payment does not grab a large token, and equal-amount candidates are
   shuffled so contention does not simply shift onto whichever token sorts
   first.
3. Sufficiency-window randomization: once the ascending scan reaches a token
   that alone covers the remaining amount, a bounded lookahead picks uniformly
   among similarly-sized candidates - count-capped by sufficiencyWindow and
   magnitude-capped by maxSufficiencyRatio, anchored on the anchor token itself
   so the window does not collapse to size 1 when every candidate dwarfs the
   request.
4. Blacklisting: a token that lost a lock race is skipped for the rest of that
   Select call instead of being retried in a tight loop for minutes.
5. Immediate lock release on settlement: a transaction reaching a terminal
   status releases its locks through the finality listener and the recovery
   handler, rather than holding them until the lease-expiry sweep. Busy and
   Unknown are explicitly non-terminal and leave locks alone; OnError and a
   retry-exhausted OnStatus release exactly once.
6. Postgres lock strategies: onConflict and skipLocked avoid server-side
   unique-constraint violations on lost races, and LockBatch claims a covering
   window of candidates in one round trip.

Review follow-ups folded in: the fast-fail balance check compares against the
full requested quantity rather than the remaining amount, which it had been
double-counting; a batch-lock store error refetches the window instead of
silently dropping it, bounded by the existing retry budget; the EVM recovery
handler releases selection locks like its Fabric counterpart; the now-unused
HasAnySpendableTokens is removed from the driver interface and its
implementations; and benchmark_test.go's pre-existing ireturn/thelper lint
breakage is fixed.

Two accuracy fixes from the last review round: StaleCandidates is incremented on
the single-token lock path only - LockBatch reports just the tokens it won, so a
stale candidate is indistinguishable there from a lost race and is booked as
LockConflicts - which the counter's own documentation and the metrics page had
claimed otherwise, and the batch branch now records the three consequences that
follow from it. maxSufficiencyRatio also gains deterministic coverage: in both
sufficiency-window tests the count cap binds first, so the ratio bound could be
disabled without either of them noticing.

Third review round (review 5404492139 on #2410): the simple driver's bounded-pool
deadlock is now fixed rather than documented as a known limitation. selectByID
held its unspentTokens cursor open across the nested concurrencyCheck query, so
every in-flight Select pinned two connections and a pool smaller than the
concurrent-selector count deadlocked outright - each connection handed to an open
cursor, each goroutine blocked waiting for a second one. The candidate scan is
already finished with the cursor by then and a retry opens a fresh one, so it is
closed before the re-check and one selection needs one connection.
TestSimpleDriverBoundedPool pins it with 16 concurrent selectors against a pool of
2, and stalls if the overlapping checkout is restored. The same round adds
idx_spendable_amount on (owner_wallet_id, token_type, amount), partial on the
spendable-tokens query's own is_deleted/owner/spendable predicates, so its ORDER BY
amount reads rows already ordered instead of sorting the wallet - a new index name
rather than an extra column on idx_owner_wallet_part, since CREATE INDEX IF NOT
EXISTS would not replace an index already deployed under that name. Plus the
documented assumption behind bucketedIterator's bucket boundaries: they use string
equality on the stored quantity, which identifies equal amounts only because that
encoding is canonical while the ORDER BY is numeric, so a non-canonical encoding
would degrade the shuffle to a no-op rather than produce a wrong order.
IsTerminalStatus is left exported - cmd/tokendiag/cobra/locks/runner.go is a
production caller, so it is not test-only.

Tests: hot-token contention suites reproducing the CERT incident's Pareto shape
and its static-hot-token variant, a simple-driver baseline, lock-outcome
classification across both the single-token and batch paths, stale-candidate
handling on both, the sufficiency-window and ratio-boundary ordering tests, and
finality listener/recovery coverage for every status transition that touches
locks.

build(tools): bump staticcheck to v0.8.1 so make checks runs under Go 1.27

staticcheck v0.7.0 panics in its own IR builder (unexpected expr:
*ast.KeyValueExpr) against this module's Go 1.27.1 toolchain, on packages
unrelated to this change, which makes the checks-heavy stage of make checks
unrunnable locally. v0.8.1 (2026.2.1) analyses the same tree cleanly with no new
findings. Separable from the selector fix if a maintainer prefers it on its own.

Fourth review round (review 5414277618 on #2410): three Low findings.

The TokenLocks DDL was emitted twice, by common.TokenLockStore.GetSchema and
common.TokenStore.GetSchema. Both must emit it - the locker owns the table and
the token store's notLocked anti-join depends on it - but every statement is
CREATE ... IF NOT EXISTS, so whichever store initializes first wins and a future
drift between the two copies would resolve silently. They now share one
tokenLocksSchema builder, so a change reaches both or neither.

stale_candidates_total read zero on exactly the deployments that matter. Postgres
is the only BatchLocker, and its claim statement answered with just the tokens it
won, which made a stale candidate indistinguishable from a lost race: the drop was
booked as LockConflicts, tokensLockedByOthersExist was set although nobody held
the token, and the candidate cache was never told it was behind the store. That is
now fixed rather than documented, as the previous round's test comment said it
should be. LockBatch returns driver.BatchLockOutcome{Won, Stale} and the claim
classifies every candidate in the same round trip, so the batch path recovers
within the call exactly as the single-token path does. Under skipLocked the
spendability predicate is evaluated a second time without the row lock, because a
row FOR UPDATE SKIP LOCKED walks past is contended and must not be reported stale;
that split is mutation-tested. The single-token Lock path also drops its follow-up
isSpendable probe - the claim now reports the state it actually saw, one round trip
lighter on that failure path - and a compile-time assertion pins the one production
BatchLocker, a capability discovered by type assertion and so able to disappear
silently, as benchBatchLocker promptly did.

The third finding, that the !hasEnough fast-fail leaves no retry cushion on a
lagging read replica, needs no change: before this PR an empty scan with no
observed lock conflict returned SelectorInsufficientFunds from that branch
unconditionally, and that error exits StubbornSelector's backoff loop outright, so
lag failed such a call then too. The check only ever turns a give-up into a retry.
Closing the lag window itself is a read-routing question - the candidate scan reads
the same replica. The reasoning is recorded in the code and in docs/services/selector.md
so it is not re-derived.

Tests: the batch stale-candidate test flipped to the recovered behaviour it was
written to predict, a new one pinning the degraded no-classification backend, and
a real-Postgres classification test covering won/stale/contended in one claim
under both batch strategies.

Fifth review round (on #2410): two Low findings, both comment-only. The
sufficiency-window lookahead buffer is documented as bounded by
sufficiencyWindow rather than by the wallet - nextCandidate dequeues from the
buffer before it touches the cache, so a window is assembled out of the buffer
first and only sufficiencyWindow-1 entries are ever put back, including in a
wallet where every token is individually sufficient. And claimCandidates records
that its three spendability-flag placeholders appear twice in the query under
skipLocked on purpose: a Postgres $N may be referenced any number of times for a
single positional argument, so the reuse must not be mirrored by a second append
to args, and keeping them literally the same placeholders is what makes the two
CTEs provably the same predicate.

Rebased onto main after #2020 landed (perf(storage): index tokens.amount and offer a
bounded spendable query), which touched the same spendable-token query. The two are
merged rather than either side dropped:

- The duplicated idx_spendable_amount DDL - added independently by both - is emitted
  once. Both copies were textually identical, and keeping both left two %s verbs with
  no arguments, so GetSchema rendered idx_cleaned_at_%!s(MISSING) and every sqlite
  schema init failed.
- buildSpendableTokensQuery, #2020's shared builder, carries the notLocked anti-join,
  so a bounded caller cannot see candidates the unbounded iterator hides.
- SpendableTokensIteratorBy asks for AmountAscending explicitly, via
  spendableTokensIteratorByParams. #2020 made AmountUnordered the zero value and let
  the iterator take it, which is the cheaper plan in general but silently removes the
  ascending order bucketedIterator and the sufficiency window are built on.
- #2020's SQL goldens are updated to the merged shape, and its
  TestBuildSpendableTokensIteratorByQueryUnchanged - which asserted the iterator emits
  no ORDER BY and no amount - becomes TestBuildSpendableTokensIteratorByQueryShape,
  asserting the clauses the selector requires. docs/development/storage.md loses the
  claim that the selector discards the database's order.

Signed-off-by: AkramBitar <akram@il.ibm.com>
@AkramBitar
AkramBitar force-pushed the fix/2395-consolidated-3-8 branch from b46c93a to d2f1599 Compare October 6, 2026 14:05
@Effi-S
Effi-S self-requested a review October 7, 2026 09:44

@Effi-S Effi-S left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

F1 — Auditor finality listener now releases "selection locks" on every finalized tx, though an auditor performs no selection — Low

  • File / line: token/services/ttx/finality/listener.go:228 (the releaseLocks(ctx, …)
    call at the end of runOnStatus), wired for the auditor at
    token/services/auditor/auditor.go:295.
  • What: runOnStatus unconditionally calls releaseLocks → SelectorManager().Unlock(ctx, txID)
    on every terminal status (Confirmed/Deleted). This is exactly right for the owner
    path (ttx — mechanism 4, the whole point of the fix). But the auditor listener is wired
    with the same provider (auditor.go:295), and an auditor never acquires token-selection
    locks for the transactions it audits.
  • Failure scenario: for every transaction the auditor finalizes, it resolves the TMS
    selector manager and issues a DELETE FROM <TokenLocks> WHERE consumer_tx_id = <txID> that
    matches zero rows — one wasted round trip on a hot path. Worse case: if the auditor's TMS
    has no usable SelectorManager, releaseLocks logs a WARN per finalized transaction
    ("failed to get selector manager to release locks …" / "failed to release locks …"),
    i.e. steady-state log noise.
  • Background: mechanism 4 (#2395) is about the spending node leaking locks it took during
    selection. The auditor is a different role; it was wired in alongside ttx/fabric/evm for
    uniformity. Consider gating the release on "this node actually selects" (owner path only), or
    confirm the auditor's SelectorManager() resolves cheaply and silently to a no-op so there's
    no per-tx warning.

F2 — New Selector.pending field is read/written without the mutex that guards the sibling cache field — Informational / Low

  • File / line: token/services/selector/sherdlock/selector.go:77 (field decl),
    with unsynchronized access at :168 (s.pending = nil), :506 (refreshCandidates),
    and :522–529 (dequeue) / :605,:624 (nextCandidate).
  • What: s.cache is protected by s.mu specifically so a concurrent Close() can't swap
    the iterator out mid-read (next() holds s.mu). The new pending []*UnspentTokenInWallet
    slice is mutated via s.pending = s.pending[1:] / append(...) with no lock. The code
    comment states only selectInternal's single goroutine touches it.
  • Failure scenario: only if the same per-txID-cached Selector (the manager caches one
    instance per transaction.ID, manager.go:57) ran two Select calls concurrently — then
    the slice ops race (and a Close() concurrent with a pending read is also unsynchronized,
    unlike the cache read).
  • Background: this is not a regression — concurrent Select on one Selector would
    already corrupt the cache iteration logically even with the mutex (the mutex only protects
    the swap, not iteration coherence), so the single-Select-per-selector assumption predates
    this PR. Noted only because pending is a newly added shared field that, unlike cache, has
    no memory-safety guard at all. If concurrent same-txID selection is ever possible, both
    fields need rethinking; if it is contractually impossible, a one-line assertion/comment at
    the type would make the invariant explicit.

F3 — Unrecognized (default) finality status now triggers lock release after retry exhaustion — Informational

  • File / line: token/services/ttx/finality/listener.go:207–211 (the default branch
    returning an error) → :139 (OnStatus releasing locks once the retry budget is spent).
  • What: before this PR an unknown status just errored and was retried/logged. Now, after
    MaxRetry exhaustions, OnStatus releases txID's locks. The Busy/Unknown statuses are
    correctly carved out as non-terminal (:197–206) and do not release — good. But a
    genuinely unrecognized status (neither Valid/Invalid/Busy/Unknown) will, after
    retries, release locks for a transaction whose true state is unknown.
  • Failure scenario: a never-before-seen status code on a still-in-flight tx → locks freed
    early → another Select may grab those tokens. Double-spend is still prevented by the
    row-level INSERT backstop, the is_deleted predicate, and the in-same-statement
    spendability check, so the worst case is a spurious selection that fails later — not an
    incorrect ledger.
  • Background: this is a should-never-happen path (the comment acknowledges it). Flagged
    only so the behavioral change from "retry forever-ish then log" to "release locks" is a
    conscious choice; it is defensible given the lower-layer backstops.

🤖 Generated with Claude Code

The sherdlock token selector made a small number of "hot" tokens absorb the vast
majority of lock collisions, surfacing as spurious "insufficient funds" errors
under concurrent load (#2395). Six mechanisms contributed; this closes all of
them, consolidating the #2398-#2403 stack (Phases 3-8) on top of #2397's
diagnostics and baseline. Supersedes #2398, #2399, #2400, #2402, #2403.

1. Anti-join: the candidate query excludes already-locked tokens, so selectors
   stop queueing up to fight over the same row.
2. Amount-ordered candidates plus shuffle: tokens are fetched smallest-first so
   a small payment does not grab a large token, and equal-amount candidates are
   shuffled so contention does not simply shift onto whichever token sorts
   first.
3. Sufficiency-window randomization: once the ascending scan reaches a token
   that alone covers the remaining amount, a bounded lookahead picks uniformly
   among similarly-sized candidates - count-capped by sufficiencyWindow and
   magnitude-capped by maxSufficiencyRatio, anchored on the anchor token itself
   so the window does not collapse to size 1 when every candidate dwarfs the
   request.
4. Blacklisting: a token that lost a lock race is skipped for the rest of that
   Select call instead of being retried in a tight loop for minutes.
5. Immediate lock release on settlement: a transaction reaching a terminal
   status releases its locks through the finality listener and the recovery
   handler, rather than holding them until the lease-expiry sweep. Busy and
   Unknown are explicitly non-terminal and leave locks alone; OnError and a
   retry-exhausted OnStatus release exactly once.
6. Postgres lock strategies: onConflict and skipLocked avoid server-side
   unique-constraint violations on lost races, and LockBatch claims a covering
   window of candidates in one round trip.

Review follow-ups folded in: the fast-fail balance check compares against the
full requested quantity rather than the remaining amount, which it had been
double-counting; a batch-lock store error refetches the window instead of
silently dropping it, bounded by the existing retry budget; the EVM recovery
handler releases selection locks like its Fabric counterpart; the now-unused
HasAnySpendableTokens is removed from the driver interface and its
implementations; and benchmark_test.go's pre-existing ireturn/thelper lint
breakage is fixed.

Two accuracy fixes from the last review round: StaleCandidates is incremented on
the single-token lock path only - LockBatch reports just the tokens it won, so a
stale candidate is indistinguishable there from a lost race and is booked as
LockConflicts - which the counter's own documentation and the metrics page had
claimed otherwise, and the batch branch now records the three consequences that
follow from it. maxSufficiencyRatio also gains deterministic coverage: in both
sufficiency-window tests the count cap binds first, so the ratio bound could be
disabled without either of them noticing.

Third review round (review 5404492139 on #2410): the simple driver's bounded-pool
deadlock is now fixed rather than documented as a known limitation. selectByID
held its unspentTokens cursor open across the nested concurrencyCheck query, so
every in-flight Select pinned two connections and a pool smaller than the
concurrent-selector count deadlocked outright - each connection handed to an open
cursor, each goroutine blocked waiting for a second one. The candidate scan is
already finished with the cursor by then and a retry opens a fresh one, so it is
closed before the re-check and one selection needs one connection.
TestSimpleDriverBoundedPool pins it with 16 concurrent selectors against a pool of
2, and stalls if the overlapping checkout is restored. The same round adds
idx_spendable_amount on (owner_wallet_id, token_type, amount), partial on the
spendable-tokens query's own is_deleted/owner/spendable predicates, so its ORDER BY
amount reads rows already ordered instead of sorting the wallet - a new index name
rather than an extra column on idx_owner_wallet_part, since CREATE INDEX IF NOT
EXISTS would not replace an index already deployed under that name. Plus the
documented assumption behind bucketedIterator's bucket boundaries: they use string
equality on the stored quantity, which identifies equal amounts only because that
encoding is canonical while the ORDER BY is numeric, so a non-canonical encoding
would degrade the shuffle to a no-op rather than produce a wrong order.
IsTerminalStatus is left exported - cmd/tokendiag/cobra/locks/runner.go is a
production caller, so it is not test-only.

Tests: hot-token contention suites reproducing the CERT incident's Pareto shape
and its static-hot-token variant, a simple-driver baseline, lock-outcome
classification across both the single-token and batch paths, stale-candidate
handling on both, the sufficiency-window and ratio-boundary ordering tests, and
finality listener/recovery coverage for every status transition that touches
locks.

build(tools): bump staticcheck to v0.8.1 so make checks runs under Go 1.27

staticcheck v0.7.0 panics in its own IR builder (unexpected expr:
*ast.KeyValueExpr) against this module's Go 1.27.1 toolchain, on packages
unrelated to this change, which makes the checks-heavy stage of make checks
unrunnable locally. v0.8.1 (2026.2.1) analyses the same tree cleanly with no new
findings. Separable from the selector fix if a maintainer prefers it on its own.

Fourth review round (review 5414277618 on #2410): three Low findings.

The TokenLocks DDL was emitted twice, by common.TokenLockStore.GetSchema and
common.TokenStore.GetSchema. Both must emit it - the locker owns the table and
the token store's notLocked anti-join depends on it - but every statement is
CREATE ... IF NOT EXISTS, so whichever store initializes first wins and a future
drift between the two copies would resolve silently. They now share one
tokenLocksSchema builder, so a change reaches both or neither.

stale_candidates_total read zero on exactly the deployments that matter. Postgres
is the only BatchLocker, and its claim statement answered with just the tokens it
won, which made a stale candidate indistinguishable from a lost race: the drop was
booked as LockConflicts, tokensLockedByOthersExist was set although nobody held
the token, and the candidate cache was never told it was behind the store. That is
now fixed rather than documented, as the previous round's test comment said it
should be. LockBatch returns driver.BatchLockOutcome{Won, Stale} and the claim
classifies every candidate in the same round trip, so the batch path recovers
within the call exactly as the single-token path does. Under skipLocked the
spendability predicate is evaluated a second time without the row lock, because a
row FOR UPDATE SKIP LOCKED walks past is contended and must not be reported stale;
that split is mutation-tested. The single-token Lock path also drops its follow-up
isSpendable probe - the claim now reports the state it actually saw, one round trip
lighter on that failure path - and a compile-time assertion pins the one production
BatchLocker, a capability discovered by type assertion and so able to disappear
silently, as benchBatchLocker promptly did.

The third finding, that the !hasEnough fast-fail leaves no retry cushion on a
lagging read replica, needs no change: before this PR an empty scan with no
observed lock conflict returned SelectorInsufficientFunds from that branch
unconditionally, and that error exits StubbornSelector's backoff loop outright, so
lag failed such a call then too. The check only ever turns a give-up into a retry.
Closing the lag window itself is a read-routing question - the candidate scan reads
the same replica. The reasoning is recorded in the code and in docs/services/selector.md
so it is not re-derived.

Tests: the batch stale-candidate test flipped to the recovered behaviour it was
written to predict, a new one pinning the degraded no-classification backend, and
a real-Postgres classification test covering won/stale/contended in one claim
under both batch strategies.

Fifth review round (on #2410): two Low findings, both comment-only. The
sufficiency-window lookahead buffer is documented as bounded by
sufficiencyWindow rather than by the wallet - nextCandidate dequeues from the
buffer before it touches the cache, so a window is assembled out of the buffer
first and only sufficiencyWindow-1 entries are ever put back, including in a
wallet where every token is individually sufficient. And claimCandidates records
that its three spendability-flag placeholders appear twice in the query under
skipLocked on purpose: a Postgres $N may be referenced any number of times for a
single positional argument, so the reuse must not be mirrored by a second append
to args, and keeping them literally the same placeholders is what makes the two
CTEs provably the same predicate.

Rebased onto main after #2020 landed (perf(storage): index tokens.amount and offer a
bounded spendable query), which touched the same spendable-token query. The two are
merged rather than either side dropped:

- The duplicated idx_spendable_amount DDL - added independently by both - is emitted
  once. Both copies were textually identical, and keeping both left two %s verbs with
  no arguments, so GetSchema rendered idx_cleaned_at_%!s(MISSING) and every sqlite
  schema init failed.
- buildSpendableTokensQuery, #2020's shared builder, carries the notLocked anti-join,
  so a bounded caller cannot see candidates the unbounded iterator hides.
- SpendableTokensIteratorBy asks for AmountAscending explicitly, via
  spendableTokensIteratorByParams. #2020 made AmountUnordered the zero value and let
  the iterator take it, which is the cheaper plan in general but silently removes the
  ascending order bucketedIterator and the sufficiency window are built on.
- #2020's SQL goldens are updated to the merged shape, and its
  TestBuildSpendableTokensIteratorByQueryUnchanged - which asserted the iterator emits
  no ORDER BY and no amount - becomes TestBuildSpendableTokensIteratorByQueryShape,
  asserting the clauses the selector requires. docs/development/storage.md loses the
  claim that the selector discards the database's order.

Sixth review round (review 5440501678 on #2410): three findings, all about who
may release a selection lock.

The auditor's finality listener was wired with the real selector-manager
provider, although an auditor never acquires selection locks for the
transactions it audits - those belong to the node that assembled and spent them.
Every transaction it finalized therefore cost an Unlock that could only match
zero rows, and a WARN per transaction on a TMS with no usable selector manager.
finality.NoSelectorManagerProvider resolves to no selector manager, which
releaseLocks already treats as nothing to release, and is wired into
auditor.Service.Append and into the evm driver's recovery handler over the audit
store, which had the same problem for the same reason; the ttx and
transaction-store paths keep the real provider.

The sufficiency-window lookahead buffer was shared mutable state on Selector,
like cache, but with no memory-safety guard: pending was mutated by slice
assignment and append under no lock, while a concurrent Close could write the
same field. Every read and write now goes through dequeue, requeue or
dropPending, and swapCache and Close clear it, all under s.mu. dequeue makes the
closed check first and under that lock, which closes a second gap: a buffered
candidate could previously be handed out by an already-closed selector. next()
folds into dequeue.

A finality status the listener cannot classify says nothing about where the
transaction actually stands, so releasing its locks could hand in-flight tokens
to a concurrent Select. runOnStatus now reports ErrUnrecognizedStatus, which
OnStatus neither retries - calling runOnStatus again with the same arguments can
never reclassify the status, so the retry budget and its backoff sleeps bought
nothing - nor releases on. Those locks are left to the lease-expiry sweep,
exactly as for Busy and Unknown. A recognized terminal status whose local
persistence keeps failing still releases, exactly once. applyFinalityLogic needed
no change: its default branch already treats an unrecognized status as
non-terminal and returns without releasing.

Tests: an auditor wiring test asserting a finalized transaction resolves no
selector manager, a NoSelectorManagerProvider unit test, a -race test driving
concurrent Select and Close over the lookahead buffer, and the two
unrecognized-status listener tests inverted to the kept-locks behaviour plus one
pinning that the status is not retried. Both new regression tests were verified
to fail against the pre-fix code.

Signed-off-by: AkramBitar <akram@il.ibm.com>
@AkramBitar
AkramBitar force-pushed the fix/2395-consolidated-3-8 branch from d2f1599 to def3585 Compare October 7, 2026 10:46
@AkramBitar

Copy link
Copy Markdown
Contributor

Thanks @Effi-S — all three in def3585b4, squashed to one commit.

F1 — auditor releasing "selection locks" it never took. Fixed, and in two places rather than one. You were right that the auditor was wired in for uniformity and that the release is the spending node's business: releaseLocks resolving a selector manager there can at best issue a DELETE matching zero rows, and on a TMS with no usable SelectorManager it logs a WARN per finalized transaction. Rather than gate the release on a role predicate inside runOnStatus, the role is now expressed in the wiring: finality.NoSelectorManagerProvider resolves to (nil, nil), which releaseLocks already treats as "nothing to release", so an auditor's listener does no TMS lookup, no round trip and no warning. auditor.Service.Append uses it.

Checking the rest of that wiring turned up the same bug a second time, which your finding did not name: the evm driver starts a recovery sweep over both the transaction store and the audit store (x/token/services/network/evm/recovery.go), and both handlers shared one real provider. The loop now pairs each store with its own, so the audit-store handler gets the no-op one. The Fabric driver only wires the transaction store, so it was already correct.

F2 — pending written without the mutex that guards cache. Fixed. Agreed on the diagnosis including the "not a regression" part, but the field is newly added and it was the one piece of shared mutable state on Selector with no memory-safety guard at all, so it is now guarded rather than documented as an invariant. All access goes through dequeue / requeue / dropPending, and swapCache / Close clear the buffer — every one of them under s.mu.

Folding the buffer into the lock also closed a gap that was not in the finding: dequeue now makes the closed check first and under the same lock, so a closed selector can no longer hand out a buffered candidate. Previously pending was consulted before next(), so Close followed by a buffered read returned a token where the cache path would have reported "already closed". next() folds into dequeue as a result. TestSelector_ConcurrentSelectAndCloseDoNotRaceOnPendingBuffer drives concurrent Select and Close over the buffer and reports DATA RACE under -race against the pre-fix code.

F3 — unrecognized status releasing locks after retry exhaustion. Changed, and in the direction your "conscious choice" framing implies it should go: it is not defensible enough to keep. The lower-layer backstops do prevent an incorrect ledger, but the status is unclassifiable precisely because nothing is known about where the transaction stands — it may well still be in flight — and that is the same situation Busy/Unknown are carved out for. So runOnStatus now reports ErrUnrecognizedStatus and OnStatus keeps the locks, leaving them to the lease-expiry sweep.

The same sentinel removes a second thing that bought nothing: that error terminates the retry loop on the first attempt, since calling runOnStatus again with identical arguments can never reclassify a status. Previously it burned MaxRetry attempts and their backoff sleeps to reach a foregone conclusion. A recognized terminal status whose local persistence keeps failing still releases exactly once, which is the case the retry-exhaustion release was really for. TTXRecoveryHandler.applyFinalityLogic needed no change — its default branch already treated an unrecognized status as non-terminal and returned without releasing, so the two paths now agree.

Tests: the two unrecognized-status listener tests are inverted to the kept-locks behaviour, plus one pinning that the status is not retried; an auditor wiring test asserting a finalized transaction resolves no selector manager at all; and a NoSelectorManagerProvider unit test. Both new regression tests were checked to fail against the pre-fix code. Docs updated in docs/services/finality.md (§3 step 8, the status list and the §8 failure table) and docs/services/selector.md (release-on-settlement, the auditor carve-out, and the buffer's locking).

make checks and make lint are clean; the affected packages pass under -race.

One unrelated find while running make unit-tests, flagged rather than fixed here since the file is on main and outside this PR's diff: TestNextDelay_FallsBackWhenJitterWouldGoNegative (token/services/utils/retry_internal_test.go) flaked once with "0s" is not positive. nextDelay's guard is finalDelay < 0, so a jittered delay that truncates to exactly 0 slips past it. Rare — it passed 200 consecutive runs afterwards — but real. Happy to open a separate issue for it.

@Effi-S Effi-S left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 More findings:

  1. Low: Batch path loses committed locks on a mid-RETURNING read failure

Where: token/services/selector/sherdlock/selector.go:406:407 (the if lockErr != nil
branch after TryLockBatch); the error originates in
token/services/storage/db/sql/postgres/tokenlock.go:301 / :316 (claimCandidates returning
driver.BatchLockOutcome{}, err from a row-scan / rows.Err() failure).

What: claimCandidates runs one WITH … INSERT … RETURNING statement: the lock rows are
committed server-side (auto-commit) and then the client reads the result set. If the connection
drops after the commit but during the read, QueryContext/rows.Scan/rows.Err()
returns an error and claimCandidates returns an empty outcome plus that error. In
selectInternal, the lockErr != nil branch (:407) logs, bumps LockStoreErrors, charges a
retry and refreshCandidates — discarding outcome entirely. The tokens were really locked
(rows exist with our consumer_tx_id), but they're never added to selected.

How it fits / blast radius: The just-locked tokens now have TokenLocks rows, so the
notLocked anti-join (mechanism 3) hides them from this call's refetch and from every other
selector. They can't be re-selected. If the overall Select then succeeds via other tokens,
selectWithoutMetrics only calls UnlockAll on an error return, so these stray locks are not
cleaned up by this call — they persist until this same tx settles (mechanism 4's
Unlock(txID) deletes all rows for the consumer_tx_id, strays included) or the lease-age
sweep. So it is self-healing at settlement, not a permanent leak, and requires a precise
connection failure window — hence Low.

Why it's still worth noting: It's an asymmetry with the single-token path, which deliberately
handles the equivalent ambiguous case: common/tokenlock.go LockAt treats a RowsAffected
error as "lock acquired" (comment: "treat the lock as acquired rather than failing a selection
on a driver capability"
) and the token is added to selected. The batch path makes the
opposite choice silently.

Suggested direction: On a read error from claimCandidates, the won set is unknown but may be
non-empty; either surface that ambiguity so the caller can UnlockAll defensively, or treat the
batch like the single-token path treats its ambiguous outcome.


  1. Low: Batch path loses committed locks on a mid-RETURNING read failure

Where: token/services/auditor/auditor.go:288 constructs finality.NewListener(...) with
finality.NewSelectorManagerProvider(a.tmsProvider, a.tmsID) at :295 — the same Listener
type the ttx sender wires at token/services/ttx/db.go:70.

What: releaseLocks now fires on every terminal status the auditor's listener sees, calling
a's SelectorManager().Unlock(txID) → DELETE FROM <auditor TokenLocks> WHERE consumer_tx_id = txID. The auditor audits transactions; it does not run selection and holds no selection locks for
those txIDs. So the DELETE matches zero rows every time.

How it fits / blast radius: Pure overhead, not a correctness issue — the auditor's TMS has its
own table names, so this can't touch the sender's locks. Cost is one wasted DB round trip per
audited terminal tx, plus a WarnfContext line per tx if an audit-only TMS's
SelectorManager() returns an error (releaseLocks logs and continues on error). On a busy
auditor that's measurable log noise and load.

Suggested direction: Gate the release wiring to the sender path, or make releaseLocks a
no-op when the resolved SelectorManager has nothing to unlock (it already tolerates a nil
manager — the auditor just doesn't return nil).

Comment thread token/services/ttx/finality/listener.go Outdated
}
}

// OnError is called when a finality event for txID could not be delivered after all retries.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium

OnError releases a transaction's selection locks unconditionally. The OnStatus
retry-exhausted path does the same for whatever failed inside runOnStatus (including the
default branch) , which errors on an unrecognized status that retrying can
never reclassify.

Failure scenario: The finality framework gives up delivering a status for a tx that is still
committing on-ledger (e.g. repeated Busy, then the delivery machinery exhausts its own retries
and calls OnError). releaseLocks unlocks the tx's inputs; a concurrent Select can then pick
those still-live tokens for a second transaction. The ledger will reject the eventual
double-spend, so this is wasted/invalid work rather than an on-ledger loss — but it re-opens
exactly the window the Busy/Unknown guard was added to close.

Suggested direction: OnError has no status to key on, so it cannot know the tx is terminal;
consider not releasing there (let settlement / recovery / the lease sweep do it), or release only
when the tx's stored status is already terminal. For OnStatus, route the unrecognized-default
case the way recovery.go does (no release) rather than through the give-up release.

@Effi-S Effi-S left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

# Severity File:line Summary
1 Medium token/services/ttx/finality/listener.go:115, :139 Locks released unconditionally on OnError / retry-exhaustion, even for a possibly in-flight tx — contradicting the Busy/Unknown guard the PR itself adds.
2 Low token/services/selector/sherdlock/selector.go:407 (+ postgres/tokenlock.go:301,316) Batch path discards outcome when the RETURNING read fails after the lock rows committed, so won locks are held but never put in selected.
3 Low token/services/auditor/auditor.go:295 The shared Listener now issues an Unlock(txID) per terminal audited tx against the auditor's lock store, which never holds those locks — wasted round trip + possible per-tx warn log.
4 Info token/services/selector/sherdlock/selector.go:320 (whole selectInternal) Architectural: randomized smallest-fit covering selection is implemented as stateful app-side machinery rather than pushed into the DB layer. Not a defect.
5 High token/services/storage/tokenlockdb/store.go:21 (effect at sherdlock/selector.go:835) The entire batch-locking path (Phase 5) is dead in production. The wrapper actually wired into NewSherdSelector embeds the driver.TokenLockStore interface, which has no LockBatch, so the lockDB.(BatchLocker) assertion always fails and supportsBatch is never true. Proven by compiler.
6 Medium token/services/selector/sherdlock/fetcher.go:441 (+ :566) Cache-invalidation TOCTOU: an in-flight update() that read its snapshot before InvalidateCache() still installs that pre-invalidation snapshot and stamps it fresh, silently losing the stale-candidate refresh for ~one freshness interval.
7 Low token/services/ttx/finality/listener_test.go:598, :526; postgres/*_test.go; testutils/test_cases.go:418 Test gaps: TestCheckTokenRequest exercises the stdlib, not the SUT; TestOnError asserts nothing; the Postgres strategy tests silently skip with no DB; a copied error-collector goroutine has a latent drop-errors race.

…nknown status (#2395)

Second review round on #2410, six fixes.

The batch path (mechanism 6) was unreachable in every deployment. The manager's
Locker is a *tokenlockdb.StoreService, which embeds the driver.TokenLockStore
interface; embedding an interface promotes only its method set, and no driver
interface declares LockBatch, so the lockDB.(BatchLocker) assertion always
failed. StoreService.Unwrap (nil-receiver safe) exposes the wrapped store and
sherdlock.asBatchLocker looks at both the value and the store it wraps. The old
conformance assertion named *postgres.TokenLockStore, a type the selector is
never handed, so it is replaced by tests over the real wrapper, covering the
batch-incapable and both nil cases.

OnError released the locks of a transaction whose status is unknown. The EVM
driver calls it precisely when no verdict could be obtained, so it now keeps
them; the recovery and lease-expiry sweeps are the backstops that need no
verdict.

The shared TokenLocks DDL ran under two different Postgres advisory locks:
prefixSchemaWithLocks takes them in ascending, deduplicated order - so sharing a
subset cannot deadlock - and the token store now takes the TokenLocks lock as
well as its own.

The Fabric audit-store recovery handler still wired the real selector manager
provider, the defect fixed on the EVM side last round; createRecoveryManager
takes the provider as a parameter.

The batch window was uncapped, so a dust-heavy wallet could exceed Postgres's
65535 bind-parameter limit; maxBatchWindow caps it at 256 and the outer loop
claims another window when needed.

A detached godoc comment on SpendableTokensIteratorBy is reattached.

Signed-off-by: AkramBitar <akram@il.ibm.com>
…ial batch locks in use (#2395)

Third review round on #2410: the three findings that were still open.

An update() whose store read began before InvalidateCache() installed its
snapshot anyway and stamped it fresh, so the invalidation was lost for a whole
freshness interval. The only re-check before installing was staleness, which an
invalidation satisfies by construction. cachedFetcher now carries an
invalidations counter, read under the lock before the snapshot is taken and
re-read before it is installed; a snapshot that predates an invalidation is
discarded with lastFetched left at zero, so readers keep seeing the old snapshot
- the documented InvalidateCache contract - and the next read refetches, after
the invalidation.

A batch claim is a single statement that has committed by the time its result
set is read, so a failure mid-read leaves lock rows held by the consumer tx.
claimCandidates returned an empty outcome in that case and the selector
discarded it, which held those locks, unusable and unreported, until the
lease-expiry sweep. The store now returns the verdicts it did read alongside the
error and the selector's store-error branch harvests outcome.Won into selected,
resetting the immediate-retry budget exactly as the success path does, before
refetching the unread remainder; a harvest that covers the request returns. The
contract is spelled out on driver.BatchLockOutcome and sherdlock.BatchLocker.

Test gaps, four of them. TestCheckTokenRequest asserted on encoding/base64
rather than on the guard, which is unexported and so unreachable from
finality_test: it is now a table-driven internal test that runs every case
through both Listener.checkTokenRequest and
TTXRecoveryHandler.checkTokenRequest, so the two copies cannot drift. TestOnError
asserted nothing and now pins what OnError must not do for a transaction with no
verdict: no SetStatus, no NewTransaction, no NotifyStatus. The Postgres
lock-strategy tests skipped silently without a server; a shared startPostgres
helper names the reason and the variable that changes it, and fails rather than
skips when PANURUS_REQUIRE_POSTGRES is set, so an environment that is supposed to
have Docker cannot report green with the strategy tests never run. And the three
parallelSelect* helpers collected per-worker errors behind a mutex their
collector goroutine held for its lifetime, with nothing ordering that goroutine
before the caller, which could take the mutex first and return the still-empty
slice - an errorCollector whose Wait closes the channel and waits for the drain
replaces it.

Both behavioural fixes were verified to fail against the pre-fix code
(TestCachedFetcher_DiscardsSnapshotInvalidatedMidRead,
TestBatchLockPartialOutcome_TakesTheWonLocks).

Review finding 4 of that round is an architectural note on selectInternal,
explicitly not a defect, and is left as it stands.

Signed-off-by: AkramBitar <akram@il.ibm.com>
…and partial batch claims

The cache-refresh section described only the time- and query-based triggers, so
the explicit InvalidateCache path - and the reason it needs more than clearing a
timestamp, since a refresh already reading from the store would otherwise install
its pre-invalidation snapshot and stamp it fresh - went unmentioned. The
immediate-retry paragraph likewise described only a batch claim that fails
outright, not one that fails after committing, whose reported winners the
selector keeps rather than abandoning their lock rows to the lease sweep.

Signed-off-by: AkramBitar <akram@il.ibm.com>
…ates

The comment added with the partial-outcome harvest claimed the refetch makes the
unclassified remainder visible to the next scan. It does not: a candidate this
claim actually won has a TokenLocks row, which the notLocked anti-join then hides
from the refetch and from every other selector, so this call cannot recover it.
It is released when the transaction settles - Unlock(txID) deletes every row for
the consumer tx, strays included - with the lease-expiry sweep behind that.

Signed-off-by: AkramBitar <akram@il.ibm.com>
@AkramBitar

Copy link
Copy Markdown
Contributor

Thanks @Effi-S — this covers the two reviews that hadn't been answered yet, and corrects something I got wrong in my previous comment.

Pushed as four commits on top of def3585b4 rather than squashed, deliberately: the previous rounds were squashed, which made "what changed since my last review" invisible. Happy to squash before merge.

Correction to my last comment

I wrote that the Fabric driver "only wires the transaction store, so it was already correct". That was wrong. createRecoveryManager wired the real selector-manager provider over the audit store too, exactly the defect I'd just fixed on the EVM side. It now takes the provider as a parameter, so the audit store gets NoSelectorManagerProvider and only the ttx store keeps the real one. Sorry for the noise.

Review 5442615457 (2 findings)

1 — batch path loses committed locks on a mid-RETURNING read failure. Fixed, taking your second suggested direction. claimCandidates now returns the verdicts it did read alongside the error instead of an empty outcome, and selectInternal's store-error branch harvests outcome.Won into selected/sum — resetting the immediate-retry budget exactly as the success path does — before refetching. A harvest that already covers the request returns straight away.

Your blast-radius analysis is what shaped the fix, including the limit of it: candidates the statement won but never reported are still unrecoverable by this call, because the notLocked anti-join hides them. Those are released at settlement when Unlock(txID) deletes every row for the consumer tx, strays included, with the lease sweep behind it — your "self-healing at settlement", now stated as such in the code. Harvesting bounds the loss to the rows that were genuinely unreadable rather than the whole window.

2 — the auditor's listener unlocking locks it never took. Fixed in the previous round (NoSelectorManagerProvider), plus the Fabric recovery path above that I had wrongly reported as unaffected.

Review 5443625924 (7 findings)

# Status
1 Medium — locks released on OnError / retry exhaustion Fixed: OnError keeps them; an unrecognized status terminates without releasing
2 Low — outcome discarded on a post-commit read failure Fixed, as above
3 Low — auditor Unlock per audited tx Fixed
4 Info — app-side selection machinery vs. pushing it into the DB See below
5 High — the entire batch path is dead in production Fixed: StoreService.Unwrap + asBatchLocker look at the value and the store it wraps
6 Medium — cache-invalidation TOCTOU Fixed: invalidation counter, snapshot discarded if overtaken
7 Low — test gaps All four fixed

On #5, you were right that the compiler proves it: embedding driver.TokenLockStore promotes only that interface's method set, and no driver interface declares LockBatch, so lockDB.(BatchLocker) could never succeed on the wrapper the manager is actually built with. The old conformance test asserted on *postgres.TokenLockStore, a type the selector is never handed, which is why it stayed green — it is replaced by tests over the real wrapper, including the batch-incapable and both nil cases.

On #6, the invalidation was lost rather than merely delayed: the only pre-install re-check was staleness, which an invalidation satisfies by construction, so a snapshot read before the invalidation was installed and stamped fresh. cachedFetcher now counts invalidations, reads the count under the lock before releasing it for the query, and discards a snapshot that was overtaken — leaving lastFetched at zero, so readers keep the previous snapshot (the documented contract) and the next read refetches after the invalidation.

On #7, all four: TestCheckTokenRequest was asserting on encoding/base64 because the guard is unexported and the file is package finality_test — it is now a table-driven internal test that runs every case through both Listener.checkTokenRequest and TTXRecoveryHandler.checkTokenRequest, so the two copies cannot drift. TestOnError now pins what must not happen for a verdict-less transaction: no SetStatus, no NewTransaction, no NotifyStatus. The Postgres strategy tests go through a shared startPostgres that names the reason it skipped and fails instead when PANURUS_REQUIRE_POSTGRES is set, so an environment meant to have Docker cannot report green with that coverage never run. And the error collector in testutils/test_cases.go was genuinely racy — the collecting goroutine held the mutex for its lifetime, but nothing ordered it before the caller, which could take the mutex first and return the still-empty slice — so all three parallelSelect* helpers now use an errorCollector whose Wait closes the channel and waits for the drain.

On #4, agreed that it is not a defect, and I am not changing it in this PR. The randomized smallest-fit window is app-side because two of its inputs are per-Select state the database does not have: the blacklist of candidates this call already lost races on, and the running sum against the request. The parts that are set-based have been pushed down — the anti-join, ORDER BY amount, the batched claim with its per-candidate classification, and SKIP LOCKED. Pushing the window itself down would mean a stored procedure or a statement that takes the blacklist as a parameter; worth its own issue if you want it explored, not a change I would make under this one.

Verification

Both behavioural fixes have regression tests verified to fail against the pre-fix code: TestCachedFetcher_DiscardsSnapshotInvalidatedMidRead and TestBatchLockPartialOutcome_TakesTheWonLocks. make checks and make lint exit 0; sherdlock and ttx/finality pass under -race. Docs updated for the invalidation rule and the partial-claim behaviour.

Ready for another round.

@AkramBitar
AkramBitar requested a review from Effi-S October 8, 2026 09:00
@@ -469,7 +470,9 @@ func (n *Network) connect(ns string) ([]token2.ServiceOption, error) {
if err != nil {
return nil, errors.Wrapf(err, "failed to get audit storage for [%s]", tmsID)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Nit
createRecoveryManager unconditionally wires the recovery handler with the
real ttxfinality.NewSelectorManagerProvider(...) (line 517)

if errors.Is(err, ErrUnrecognizedStatus) {
// The transaction's real state is unknown, so its locks stay held: see
// ErrUnrecognizedStatus.
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Low
When the backoff select in RunWithErrorsContext observes a cancelled context
it returns ctx.Err() (token/services/utils/retry.go:185-186). Back in OnStatus, that error
is neither nil nor ErrUnrecognizedStatus, so control falls through the if errors.Is(err, ErrUnrecognizedStatus) guard (line 148) to the unconditional releaseLocks(newCtx, ...) at line
160. A context cancellation (shutdown / interruption) is thus classified identically to
"recognized terminal status whose local persistence kept failing", and a release is attempted
even though the transaction's true state was never established.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sherdlock selector: hot-token lock contention causes false insufficient-funds under load

3 participants