Conversation
This was referenced Sep 23, 2026
adecaro
marked this pull request as draft
September 23, 2026 05:03
adecaro
marked this pull request as ready for review
September 23, 2026 05:06
adecaro
added this pull request to stack #2411
September 23, 2026 05:09
adecaro
force-pushed
the
fix/2395-consolidated-3-8
branch
2 times, most recently
from
September 23, 2026 08:00
22c893d to
1bb5da2
Compare
adecaro
force-pushed
the
fix/2395-consolidated-3-8
branch
from
September 23, 2026 08:44
1bb5da2 to
0b47be8
Compare
Contributor
SummaryThe 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:
How:
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 |
Effi-S
requested changes
Sep 27, 2026
Base automatically changed from
fix/2395-sherdlock-lock-contention-diagnostics
to
main
September 29, 2026 12:40
AkramBitar
force-pushed
the
fix/2395-consolidated-3-8
branch
from
September 29, 2026 15:15
3517dda to
790ec0a
Compare
AkramBitar
added a commit
that referenced
this pull request
Sep 29, 2026
Addresses the five findings raised in review of #2410. metricsdoc: the LockStoreErrors counter was added to sherdlock's Metrics without regenerating testdata/metrics.golden, so TestMetricsReference failed on this branch - the SDK registered a metric the golden file did not list. docs/development/metrics.md already documented it, so only the generated file was stale; regenerated with UPDATE_GOLDEN=1. StubbornSelector: the spurious zero the review flagged is not reachable against current main. Its merged design threads one caller-owned attempted set through selectInternal and observes it through observeDistinctTokensAttempted, which skips an empty set, so a closed selector or an unparseable quantity - neither of which touches the fetcher or the locker - produces no observation at all, "not even zero" as Select's comment puts it. This branch's own -1 sentinel plumbing for the same guarantee was therefore dropped in favour of the upstream one while rebasing, and what it keeps is the part main lacks: ImmediateRetries accumulated across the backoff legs, since that metric has no set to union into and a caller wants the cost of the whole outer Select call rather than of its last leg. The two aggregate differently - distinct tokens union, retry events sum - which TestStubbornSelector_AggregatesRetryMetricsAcrossBackoff now pins: two legs contending the same token is a fan-out of one, not one count per leg. The two invalid-input regression tests that guard the skip are kept. Lookahead buffer: refreshCandidates drops s.pending whenever it installs a fresh cache, but its budget-exceeded early return happens before that, so the leg that gives up on SelectorSufficientButLockedFunds handed the buffer on intact. The whole point of the backoff that follows is to re-examine the world once other processes have had a chance to release their locks, so the next leg must start from a fresh fetch - instead it silently consumed candidates peeked from the pre-backoff snapshot, bypassing both the refetch and the fresh set's randomized window. selectInternal now clears s.pending on entry; dropping is lossless, since the cache those candidates came from is still installed and re-offers them once it exhausts. Blacklisted candidates: nextCandidate let tokens this call has already lost a lock race on into the sufficiency window. They cannot be locked at all, so this spent part of the randomized pick on a guaranteed no-op - diluting the very contention spread the window exists to provide - and re-buffered the blacklisted pick into s.pending, where the next call built another window around it and skipped it again. A scan over a fully blacklisted cache therefore re-walked it once per candidate: 42 cache reads instead of 33 in the test's 3-token wallet. A blacklisted candidate is now returned straight away with no lookahead, leaving selectInternal's blacklist branch the single owner of the tokensLockedByOthersExist bookkeeping, and dropped rather than re-buffered while growing a window - mirroring what selectInternal's own batch-window loop already does. Which tokens end up selected is unchanged: the iterator yields ascending amounts, so a blacklisted anchor's threshold can never exceed the next free anchor's, and it cannot pull in a token the skip would exclude. What changes is wasted work and the pick distribution, so the regression test asserts the work bound. created_at scanning: scannableTime exists because sqlite hands back created_at as raw text - the shared schema declares it TIMESTAMPTZ, which is not one of the DATE/DATETIME/TIMESTAMP names modernc.org/sqlite converts to a native time.Time. But which text it is depends on the writer and on the driver's _time_format, and the scanner pinned itself to the single layout modernc happens to default to, so ListLocks failed outright on any value that deviated - most sharply on a time.Time still carrying a monotonic reading, whose String() form gains a trailing " m=+<seconds>" that no layout matches. That shape is not reachable today: LockAt binds createdAt.UTC() and Time.UTC() drops the reading, while the Postgres tryInsertOnConflict path never goes through the sqlite text encoding at all. This is hardening rather than a live bug fix - a reader that breaks on the one shape a forgotten .UTC() produces, in a diagnostic path whose whole job is to report lock age, is needlessly brittle. parseSQLTime now strips the monotonic suffix and tries the layouts in sqliteTimeLayouts in turn, covering the abbreviation-less zone form, RFC 3339 and zone-less timestamps alongside modernc's default. Also fixes an unrelated lint break inherited from c77b1f4: testifylint's float-compare rule rejects assert.Equal on a float64, so the two LockStoreErrors assertions failed `make lint`. Switched to assert.InEpsilon with the 0.0001 epsilon the repo already uses (token/services/auditor/config_test.go). docs/services/selector.md documents the two new sufficiency-window rules. Signed-off-by: AkramBitar <akram@il.ibm.com>
AkramBitar
force-pushed
the
fix/2395-consolidated-3-8
branch
from
September 30, 2026 09:59
790ec0a to
b856a75
Compare
AkramBitar
added a commit
that referenced
this pull request
Sep 30, 2026
Addresses the five findings raised in review of #2410. metricsdoc: the LockStoreErrors counter was added to sherdlock's Metrics without regenerating testdata/metrics.golden, so TestMetricsReference failed on this branch - the SDK registered a metric the golden file did not list. docs/development/metrics.md already documented it, so only the generated file was stale; regenerated with UPDATE_GOLDEN=1. StubbornSelector: the spurious zero the review flagged is not reachable against current main. Its merged design threads one caller-owned attempted set through selectInternal and observes it through observeDistinctTokensAttempted, which skips an empty set, so a closed selector or an unparseable quantity - neither of which touches the fetcher or the locker - produces no observation at all, "not even zero" as Select's comment puts it. This branch's own -1 sentinel plumbing for the same guarantee was therefore dropped in favour of the upstream one while rebasing, and what it keeps is the part main lacks: ImmediateRetries accumulated across the backoff legs, since that metric has no set to union into and a caller wants the cost of the whole outer Select call rather than of its last leg. The two aggregate differently - distinct tokens union, retry events sum - which TestStubbornSelector_AggregatesRetryMetricsAcrossBackoff now pins: two legs contending the same token is a fan-out of one, not one count per leg. The two invalid-input regression tests that guard the skip are kept. Lookahead buffer: refreshCandidates drops s.pending whenever it installs a fresh cache, but its budget-exceeded early return happens before that, so the leg that gives up on SelectorSufficientButLockedFunds handed the buffer on intact. The whole point of the backoff that follows is to re-examine the world once other processes have had a chance to release their locks, so the next leg must start from a fresh fetch - instead it silently consumed candidates peeked from the pre-backoff snapshot, bypassing both the refetch and the fresh set's randomized window. selectInternal now clears s.pending on entry; dropping is lossless, since the cache those candidates came from is still installed and re-offers them once it exhausts. Blacklisted candidates: nextCandidate let tokens this call has already lost a lock race on into the sufficiency window. They cannot be locked at all, so this spent part of the randomized pick on a guaranteed no-op - diluting the very contention spread the window exists to provide - and re-buffered the blacklisted pick into s.pending, where the next call built another window around it and skipped it again. A scan over a fully blacklisted cache therefore re-walked it once per candidate: 42 cache reads instead of 33 in the test's 3-token wallet. A blacklisted candidate is now returned straight away with no lookahead, leaving selectInternal's blacklist branch the single owner of the tokensLockedByOthersExist bookkeeping, and dropped rather than re-buffered while growing a window - mirroring what selectInternal's own batch-window loop already does. Which tokens end up selected is unchanged: the iterator yields ascending amounts, so a blacklisted anchor's threshold can never exceed the next free anchor's, and it cannot pull in a token the skip would exclude. What changes is wasted work and the pick distribution, so the regression test asserts the work bound. created_at scanning: scannableTime exists because sqlite hands back created_at as raw text - the shared schema declares it TIMESTAMPTZ, which is not one of the DATE/DATETIME/TIMESTAMP names modernc.org/sqlite converts to a native time.Time. But which text it is depends on the writer and on the driver's _time_format, and the scanner pinned itself to the single layout modernc happens to default to, so ListLocks failed outright on any value that deviated - most sharply on a time.Time still carrying a monotonic reading, whose String() form gains a trailing " m=+<seconds>" that no layout matches. That shape is not reachable today: LockAt binds createdAt.UTC() and Time.UTC() drops the reading, while the Postgres tryInsertOnConflict path never goes through the sqlite text encoding at all. This is hardening rather than a live bug fix - a reader that breaks on the one shape a forgotten .UTC() produces, in a diagnostic path whose whole job is to report lock age, is needlessly brittle. parseSQLTime now strips the monotonic suffix and tries the layouts in sqliteTimeLayouts in turn, covering the abbreviation-less zone form, RFC 3339 and zone-less timestamps alongside modernc's default. Also fixes an unrelated lint break inherited from c77b1f4: testifylint's float-compare rule rejects assert.Equal on a float64, so the two LockStoreErrors assertions failed `make lint`. Switched to assert.InEpsilon with the 0.0001 epsilon the repo already uses (token/services/auditor/config_test.go). docs/services/selector.md documents the two new sufficiency-window rules. Signed-off-by: AkramBitar <akram@il.ibm.com>
AkramBitar
force-pushed
the
fix/2395-consolidated-3-8
branch
2 times, most recently
from
October 2, 2026 09:23
8e458e1 to
661291a
Compare
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. 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
force-pushed
the
fix/2395-consolidated-3-8
branch
from
October 2, 2026 11:18
661291a to
583e600
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Select()call, instead of being retried in a tight loop for minutes.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
bucketedIterator/NewPermutationshuffle only randomizes within contiguous runs of byte-equalQuantity; 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 bysufficiencyWindow, magnitude-capped bymaxSufficiencyRatioso it can't grab a wildly oversized token) and picks uniformly at random among the sufficient candidates in that window. New testTestSizeOrderedSelection_SufficiencyWindowShuffleproves real spread among several distinct, individually-sufficient amounts while the existing deterministic smallest-fit test still holds.Listener.OnErrornever released locks, unlikeOnStatus/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.OnErrornow calls the samereleaseLockshelper asrunOnStatus; new testsTestOnError_ReleasesLocksandTestOnError_LockReleaseErrorDoesNotPropagatecover it.Follow-up: independent review, 6 fixes (TDD, each with its own commit)
Listener.runOnStatus'sdefault:branch released locks on non-terminalnetwork.Busy/Unknownstatuses — these reachOnStatusin normal operation, not just error paths; releasing locks mid-commit let a concurrentSelecthand the same tokens to a second transaction. Fixed by adding explicitcase network.Busy, network.Unknown:that returnsnilwithout touching locks or stores. New tests:TestOnStatus_DoesNotReleaseLocksOnNonTerminalStatus,TestOnStatus_ReleasesLocksOnceOnRetryExhaustion,TestOnStatus_ReleasesLocksExactlyOnceOnUnrecognizedStatus.HasEnoughSpendableTokensfast-fail double-counted tokens the current call had already locked — the comparison was againstremaining(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 fullquantity. New test:TestSelectorFastFail_PartiallyFilledRequest.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.maxImmediateRetriesbudget). New tests:TestBatchLockStoreError_WindowIsRefetchedNotDropped,TestBatchLockStoreError_TerminatesWithinRetryBudget.HasAnySpendableTokenswas dead code — added to the publicdriver.TokenStoreinterface in Phase 4a, superseded byHasEnoughSpendableTokensin Phase 6, with zero remaining production callers. Removed from the interface, all implementations, and mocks.benchmark_test.go— 4ireturn+ 1thelperissue 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 nestedx/token/services/network/evmmodule, which./...from the root does not reachgo test -race ./token/services/selector/... ./token/services/ttx/... ./token/services/storage/...— 0 failures, no data races, including the Docker-backedTestHotTokenContention*/TestStaticHotTokenContentionParetosuites, the ordering/OnError tests, and the review-fix tests abovemake checks— both halves green:checks-fast(license, gofmt, goimports, misspell, ineffassign, protos-lint, buf-format,✓ All Go modules are tidy) andchecks-heavy(go vet,go fix,staticcheck,govulncheck— the 2 reachable vulnerabilities are the ones already ingovulncheck-allowlist.txt, treated as a pass as onmain). This needed thestaticcheckv0.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", whileTestTMSScopedProviderWiringIsIntact(token/services/metricsdoc) greps the repo withfilepath.WalkDir, which does not follow symlinks. Run from the worktree path, the only failure is the former; run through a…/panurussymlink, 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 namedpanurus.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
T1label across all three infra types (websocket, libp2p, replicas). Thetransaction … is not valid [Deleted]errors in the dlog log are asserted by the suite itself (integration/token/fungible/tests.go:550passes"is not valid"as an expected message), not failures.Fixes #2395
🤖 Generated with Claude Code