Skip to content

limit the number of tokens a transaction retrieves and locks - #1707

Open
HayimShaul wants to merge 1 commit into
mainfrom
1641_bound_work_by_request
Open

HayimShaul wants to merge 1 commit into
mainfrom
1641_bound_work_by_request

Conversation

@HayimShaul

@HayimShaul HayimShaul commented May 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #1641

Imposes hard, configurable upper bounds on every dimension of work reachable from a Selector.Select call, so that a request cannot consume unbounded memory, CPU, storage or wall-clock time no matter how it is crafted. A request that would exceed a bound is aborted and its locks released.

Bounds

All under the token.selector.limits config key:

Limit Default Bounds
maxTokensPerSelection 10000 tokens iterated per selection cycle; also pushed down into SQL as a row limit
maxLockAttempts 50000 lock attempts per selection
maxRetries 10 outer retry/back-off cycles
maxLocksPerTransaction 5000 locks held concurrently by one transaction
selectionTimeout derived, see below wall-clock time for the whole selection

The row limit is pushed into the query rather than applied in Go, so a wallet with millions of tokens no longer streams all of them into the view service. Tokens come back largest-first (ORDER BY amount DESC) so a selection reaches its target in the fewest rows.

Adds the token.SelectorTimedOut sentinel, so callers can tell a timeout apart from a permanent failure. token.SelectorRateLimited (pre-existing) is now what both built-in lockers return when a transaction hits maxLocksPerTransaction, making that a fail-fast condition rather than something callers retry.

Configuration is validated at startup; an invalid or unparseable token.selector block logs and falls back to defaults.

Behaviour changes reviewers should know about

  • selectionTimeout default is no longer a flat 30s. It is 30s + maxRetries * retryInterval, i.e. 80s with the defaults. A flat 30s expires while the retries meant to resolve contention are still backing off, turning ordinary contention into a timeout the caller cannot resolve. An explicitly configured value is used verbatim. A non-positive value means no timeout.
  • A full page of tokens with insufficient funds now reports the iteration limit instead of retrying. The query ordering is deterministic, so a retry re-reads the same page; the wallet simply holds more tokens than the selection is permitted to examine.
  • Contention no longer surfaces as an untyped error. The iteration budget applies per retry cycle, so the typed reason (SelectorSufficientButLockedFunds) reaches the caller instead of being replaced by a generic limit error on a later cycle.

Docs

docs/security/selector_resource_limits.md documents each limit, its default, when it triggers, and tuning guidance.

Testing

Unit tests per limit, plus a dbtest case that exercises the limited query against both SQLite and PostgreSQL — the two dialects emit placeholders differently, so the shared case is what keeps the pushed-down limit honest on both.


Follow-up commit d1cb0422 addresses the review in this comment: the pushed-down limit was emitted with a ? placeholder (invalid on PostgreSQL), was counted before de-duplication, and the release-before-back-off in StubbornSelector had been dropped. It also restores the two test relaxations those regressions required.

@HayimShaul HayimShaul added this to the Q2/26 milestone May 14, 2026
@HayimShaul HayimShaul self-assigned this May 14, 2026
@HayimShaul
HayimShaul force-pushed the 1641_bound_work_by_request branch 4 times, most recently from dda132d to 142170c Compare May 21, 2026 10:00
@HayimShaul
HayimShaul marked this pull request as ready for review May 24, 2026 07:42
@HayimShaul
HayimShaul requested a review from adecaro May 24, 2026 07:42
@adecaro

adecaro commented May 26, 2026

Copy link
Copy Markdown
Contributor

Hi @HayimShaul , should these changes apply also to sherdlock?

@HayimShaul
HayimShaul force-pushed the 1641_bound_work_by_request branch 2 times, most recently from 0a0b6ca to 2662935 Compare May 31, 2026 11:07
Comment thread token/services/selector/sherdlock/manager.go Outdated
Comment thread token/services/selector/simple/manager.go
Comment thread token/services/selector/sherdlock/selector.go Outdated
Comment thread token/services/selector/sherdlock/selector_shutdown_test.go Outdated
@adecaro

adecaro commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

@HayimShaul , don't we need to enforce limit also at the fetcher level token/services/selector/sherdlock/fetcher.go when the token db is queried?

A more general question, if we say that we can only load 100 tokens at the time from the storage, what if for these 100 tokens the selection request cannot be satisfied, but if had picked the tokens differently we would have. We might reject requests that can be satisfied.

@HayimShaul

Copy link
Copy Markdown
Contributor Author

Hi @HayimShaul , should these changes apply also to sherdlock?

done

@adecaro adecaro modified the milestones: Q2/26, Q3/26 Jun 2, 2026
@HayimShaul
HayimShaul force-pushed the 1641_bound_work_by_request branch 3 times, most recently from 4ac2576 to 079ec93 Compare June 7, 2026 08:23
@HayimShaul
HayimShaul force-pushed the 1641_bound_work_by_request branch from cb59a41 to 3778cd1 Compare June 14, 2026 09:43
@AkramBitar
AkramBitar force-pushed the 1641_bound_work_by_request branch 3 times, most recently from 39378e4 to 1bdbe20 Compare June 23, 2026 12:13
@HayimShaul
HayimShaul force-pushed the 1641_bound_work_by_request branch 2 times, most recently from 2b91b1f to 8f6abe6 Compare July 1, 2026 14:50
@HayimShaul
HayimShaul force-pushed the 1641_bound_work_by_request branch 5 times, most recently from 9c40136 to e61dda0 Compare August 6, 2026 11:17
@AkramBitar
AkramBitar marked this pull request as draft August 6, 2026 12:58
@AkramBitar AkramBitar self-assigned this Aug 6, 2026
@AkramBitar
AkramBitar requested review from AkramBitar and adecaro and removed request for AkramBitar August 6, 2026 13:42
@Effi-S
Effi-S self-requested a review August 12, 2026 13:45
@LFDT-Panurus LFDT-Panurus deleted a comment from github-actions Bot Aug 13, 2026
@LFDT-Panurus LFDT-Panurus deleted a comment from github-actions Bot Aug 13, 2026
@LFDT-Panurus LFDT-Panurus deleted a comment from github-actions Bot Aug 13, 2026
@LFDT-Panurus LFDT-Panurus deleted a comment from github-actions Bot Aug 13, 2026
@LFDT-Panurus LFDT-Panurus deleted a comment from github-actions Bot Aug 13, 2026
@Effi-S
Effi-S marked this pull request as ready for review August 13, 2026 10:16
@Effi-S
Effi-S force-pushed the 1641_bound_work_by_request branch 2 times, most recently from bf26e34 to a004852 Compare August 13, 2026 11:25
@Effi-S
Effi-S marked this pull request as draft August 13, 2026 11:27
@Effi-S
Effi-S force-pushed the 1641_bound_work_by_request branch from a004852 to 43f5b6e Compare August 13, 2026 11:27
@AkramBitar

Copy link
Copy Markdown
Contributor

@adecaro

I would like to ask your help to review this PR.

Regards,
Akram

@adecaro adecaro 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.

Review: does this fully address #1641?

@HayimShaul

Yes — every Blocking finding from the earlier review round is fixed at 694ad64f7, on both the sherdlock (default) and simple drivers; what's left is test-coverage depth, not correctness.

Checked out 1641_bound_work_by_request at 694ad64f7 and verified:

CI checks / utest (race + regression) / itest (~50 legs incl. fabricx-dlog-t3) / bench-check / cgo-check / CodeQL — all SUCCESS
DCO — both commits signed off
make lint (full repo) — 17 pre-existing revive findings, none in this PR's changed files
Read in full: sherdlock/{selector,manager,bounded_locker,fetcher,service}.go, simple/{selector,manager}.go,
  simple/inmemory/locker.go, selector/config/driver.go, sdk/network/selector.go, token/selector.go
git merge-tree HEAD origin/main — 1 real conflict (dlogx/dlog_test.go), 2 clean auto-merges

I re-checked every item @AkramBitar's prior review flagged as Blocking against the current source rather than trusting the commit message's claims at face value: the sherdlock SQL LIMIT placeholder bug, StubbornSelector dropping locks before backoff, pre-dedup row limiting, the O(n²) in-memory locker scan, the cache/Close() race, fmt.Errorf usage, Validate()'s unreachable checks, the dead MaxRetryCycles field, the sqlite downgrade, sherdlock's missing lock-count bound, and the timeout/sufficient-funds mislabeling. All ten check out fixed by reading the code, not just the diff — details and file:line for each are in the local write-up. I'm not repeating any of those threads below.

CI is fully green, including the fabricx-dlog-t3 leg the final two commits specifically target.

What it fixes

# Finding Verified
1641.1 Bound token-iteration depth per Select sherdlock/selector.go (tokensIterated > s.maxTokensPerSelection) and simple/selector.go (tokensIteratedCount), both abort with a distinct error before further work
1641.2 Bound lock attempts per Select maxLockAttempts counters in both selectors
1641.3 Bound retry/back-off cycles MaxRetriesAfterBackOff (sherdlock) / Limits.MaxRetries (simple), plus SelectionTimeout as a wall-clock backstop
1641.4 Bound lock-store growth, on the default driver too sherdlock/bounded_locker.go, wired to the real SQL lock store at service.go:157 — this was the specific gap Akram's review caught at an earlier commit, now closed
1641.5 Abort early and release acquired locks on limit breach ✅ confirmed via the resolved UnlockAll-on-cancellation thread and reading the defer path in selectWithoutMetrics

All five rows check out against the current source.

Non-blocking

  • Attribution nit: the final commit message credits "a real-database exercise ... on both SQLite and PostgreSQL" to the new limit tests, but tokens_limit_test.go is sqlmock-based — the actual dual-backend coverage lives in dbtest/tokens.go's TUnspentTokensIteratorByLimit. Worth fixing the attribution so the next reader knows where the guarantee actually lives.
  • Coverage gap: sherdlock/bounded_locker_test.go only exercises the wrapper against mocks.FakeLocker; nothing drives maxLocksPerTx through the real SQL-backed lock store end-to-end. The wiring in service.go is correct today, but an integration-style test here would catch a future wiring regression that the wrapper unit tests can't see.
  • Rebase needed: git merge-tree against current main (5 commits ahead) shows one real conflict in integration/token/fungible/dlogx/dlog_test.go; wallets.go and auditor_test.go also moved on main but auto-merge cleanly.

Recommendation

No correctness blockers. Rebase past the dlogx/dlog_test.go conflict, and consider the two test-depth nits above if there's time before merge — neither should hold this up.

Comment thread token/services/storage/db/sql/common/tokens_limit_test.go
Comment thread token/services/selector/sherdlock/bounded_locker_test.go

@AkramBitar AkramBitar 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.

@HayimShaul

See my comments below.

Thanks.
Akram

@AkramBitar

Copy link
Copy Markdown
Contributor

Thanks a lot for the effort you've put into this — bounding the work a single request can trigger is the right thing to be doing, and there's a lot of careful plumbing and test coverage here.

Below is what I think needs addressing before merge. I've split it into things I'd consider blocking and smaller ones that are quick fixes.

Blocking

1. Validate() compares the raw config fields instead of the resolved limits it just computed.
token/services/selector/config/driver.go:269. Line 267 does limits := c.GetLimits() with a comment saying the relational checks should use the numbers the runtime will actually apply, but both conditions test c.Limits.* and only the error messages use limits.*. So a partially-specified config skips the check entirely:

token.selector.limits.maxLockAttempts: 100
# maxTokensPerSelection left unset

c.Limits.MaxTokensPerSelection == 0, the guard is skipped, Validate returns nil — and the effective values are maxLockAttempts=100 vs maxTokensPerSelection=10000, which is exactly the combination simple/service.go:57 describes as aborting every selection partway through. Any wallet with more than 100 tokens then fails with "exceeded max lock attempts". Same hole for maxLocksPerTransaction: 20000 with maxTokensPerSelection unset. driver_limits_test.go only exercises fully-specified configs, so this isn't covered.

2. ForgetTx on Close makes the lock ceiling per-action rather than per-transaction.
token/services/selector/sherdlock/manager.go:102. token/request.go:1621-1625 acquires a selector for r.Anchor and defers sm.Close(anchor) inside prepareTransfer, which is invoked once per Request.Transfer / Request.Redeem. For a multi-action transfer (the TransferView pattern in integration/token/fungible/views/transfer.go), action 1's locks remain in the store while its counter is dropped, so action 2 starts from a zero count — a transaction with N actions can hold up to N * maxLocksPerTransaction. That's the same "fresh budget" failure mode the EvictStaleTxState comment at lines 105-110 is written to prevent. This one probably needs a design decision rather than a patch: either the counter has to outlive the per-action selector, or the ceiling needs to be documented as per-action.

3. The new DB-side row limit never takes effect on the default driver.
token/services/selector/sherdlock/selector.go:302 passes limit = 0, and all three sherdlock fetchers explicitly ignore the parameter anyway (fetcher.go:106, :140, :338); lazyFetcher delegates to SpendableTokensIteratorBy, which takes no limit at all. Since sherdlock is defaultDriver (config/driver.go:19), the query on the default path is still unbounded. The iteration cap at selector.go:316 does bound how far the selector walks, so this isn't "no protection" — but the LIMIT that the PR adds only actually applies on the non-default simple driver. Is a follow-up intended for fetcher support, or should sherdlock bound the query itself here?

4. Concurrent Select on one Selector looks racy after the next() helper was removed.
token/services/selector/sherdlock/selector.go:263. The old next() held s.mu across s.cache.Next(), which serialized concurrent readers. Now both callers take the read side of selectMu, snapshot the same iterator into a local, and call cache.Next() concurrently — a data race on the underlying sql.Rows. On top of that, swapCache (line 379) closes s.cache while a peer goroutine may still hold that pointer in its local cache variable, which would be a use-after-close.

I derived reachability from Manager's lazy2.Provider handing back the same Selector instance for a given transaction.ID rather than from an actual concurrent caller, so please sanity-check whether two goroutines can really select under one anchor in practice. selector_concurrency_test.go covers Close-vs-Select but not Select-vs-Select.

Quick fixes

5. The reclaim path leaks the previous holder's txLocks count.
token/services/selector/simple/inmemory/locker.go:279. The stated invariant is that the counter is decremented on every delete, but the reclaim branch does a raw delete(s.locked, k) and then falls through to s.locked[k] = &lockEntry{TxID: txID} / s.txLocks[txID]++ at 306-307, so s.txLocks[observedTxID] is never decremented. Tx A locks N tokens, A becomes Deleted, B reclaims them one by one, and txLocks[A] stays at N indefinitely. On a hot wallet whose shard never empties (so pruneEmptyShard never runs) that's a dead entry per reclaimed token. Calling s.deleteLocked(k) instead should be all that's needed.

6. A freshly created counter is born stale, so a live reservation can be evicted.
token/services/selector/sherdlock/bounded_locker.go:133. counter() returns new(txLockCounter) with lastLock == 0, and Lock only calls c.touch(time.Now()) at line 74 — after the CAS reservation loop. staleAt(cutoff) is lastLock.Load() < cutoff.UnixNano(), which is true for zero. If Manager.runCleanupTick runs EvictStaleTxState in the window between LoadOrStore and touch, CompareAndDelete removes the counter the reservation was just made on, and the next Lock for that tx creates a fresh one — the transaction gets a full maxLocksPerTx budget on top of the locks it already holds. The comment at 118-120 says this race is prevented, but staleness is judged only on lastLock. Setting lastLock at construction (or before the CAS loop) closes it. Narrow window, but a one-line fix.

Non-blocking, worth a look

  • sherdlock/selector.go:316 — zero-valued MaxTokensPerSelection / MaxLockAttempts abort every selection on the first token, so NewManager(&Config{Fetcher: f, Locker: l, Precision: p}) is unusable. withSelectionTimeout (362-373) has a comment explaining why an omitted zero value has to stay harmless; suggest giving the two new int limits the same treatment and reading <= 0 as unlimited.
  • config/driver.go:116 — dropping defaultNumRetries = 3 makes GetNumRetries() fall back to GetLimits().MaxRetries (10), which derives SelectionTimeout = 10*5s + 30s = 80s. Every deployment that never set numRetries moves from ~15s to ~80s worst-case selection latency. If that's intended, it's worth a line in the docs diff.
  • sherdlock/service.go:50 and simple/service.go:62 — on Validate() failure the code does cfg = &config.Config{}, which discards retryInterval, leaseExpiry, the fetcher-cache settings and every rateLimit* field (since loader.limiter is built from cfg on the next line). One mistyped limit silently turns off per-wallet rate limiting. Resetting just cfg.Limits would match the stated intent.

@AkramBitar AkramBitar 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.

See my comments

@Effi-S

Effi-S commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Thanks a lot for the effort you've put into this — bounding the work a single request can trigger is the right thing to be doing, and there's a lot of careful plumbing and test coverage here.

Below is what I think needs addressing before merge. I've split it into things I'd consider blocking and smaller ones that are quick fixes.

Blocking

1. Validate() compares the raw config fields instead of the resolved limits it just computed. token/services/selector/config/driver.go:269. Line 267 does limits := c.GetLimits() with a comment saying the relational checks should use the numbers the runtime will actually apply, but both conditions test c.Limits.* and only the error messages use limits.*. So a partially-specified config skips the check entirely:

token.selector.limits.maxLockAttempts: 100
# maxTokensPerSelection left unset

c.Limits.MaxTokensPerSelection == 0, the guard is skipped, Validate returns nil — and the effective values are maxLockAttempts=100 vs maxTokensPerSelection=10000, which is exactly the combination simple/service.go:57 describes as aborting every selection partway through. Any wallet with more than 100 tokens then fails with "exceeded max lock attempts". Same hole for maxLocksPerTransaction: 20000 with maxTokensPerSelection unset. driver_limits_test.go only exercises fully-specified configs, so this isn't covered.

2. ForgetTx on Close makes the lock ceiling per-action rather than per-transaction. token/services/selector/sherdlock/manager.go:102. token/request.go:1621-1625 acquires a selector for r.Anchor and defers sm.Close(anchor) inside prepareTransfer, which is invoked once per Request.Transfer / Request.Redeem. For a multi-action transfer (the TransferView pattern in integration/token/fungible/views/transfer.go), action 1's locks remain in the store while its counter is dropped, so action 2 starts from a zero count — a transaction with N actions can hold up to N * maxLocksPerTransaction. That's the same "fresh budget" failure mode the EvictStaleTxState comment at lines 105-110 is written to prevent. This one probably needs a design decision rather than a patch: either the counter has to outlive the per-action selector, or the ceiling needs to be documented as per-action.

3. The new DB-side row limit never takes effect on the default driver. token/services/selector/sherdlock/selector.go:302 passes limit = 0, and all three sherdlock fetchers explicitly ignore the parameter anyway (fetcher.go:106, :140, :338); lazyFetcher delegates to SpendableTokensIteratorBy, which takes no limit at all. Since sherdlock is defaultDriver (config/driver.go:19), the query on the default path is still unbounded. The iteration cap at selector.go:316 does bound how far the selector walks, so this isn't "no protection" — but the LIMIT that the PR adds only actually applies on the non-default simple driver. Is a follow-up intended for fetcher support, or should sherdlock bound the query itself here?

4. Concurrent Select on one Selector looks racy after the next() helper was removed. token/services/selector/sherdlock/selector.go:263. The old next() held s.mu across s.cache.Next(), which serialized concurrent readers. Now both callers take the read side of selectMu, snapshot the same iterator into a local, and call cache.Next() concurrently — a data race on the underlying sql.Rows. On top of that, swapCache (line 379) closes s.cache while a peer goroutine may still hold that pointer in its local cache variable, which would be a use-after-close.

I derived reachability from Manager's lazy2.Provider handing back the same Selector instance for a given transaction.ID rather than from an actual concurrent caller, so please sanity-check whether two goroutines can really select under one anchor in practice. selector_concurrency_test.go covers Close-vs-Select but not Select-vs-Select.

Quick fixes

5. The reclaim path leaks the previous holder's txLocks count. token/services/selector/simple/inmemory/locker.go:279. The stated invariant is that the counter is decremented on every delete, but the reclaim branch does a raw delete(s.locked, k) and then falls through to s.locked[k] = &lockEntry{TxID: txID} / s.txLocks[txID]++ at 306-307, so s.txLocks[observedTxID] is never decremented. Tx A locks N tokens, A becomes Deleted, B reclaims them one by one, and txLocks[A] stays at N indefinitely. On a hot wallet whose shard never empties (so pruneEmptyShard never runs) that's a dead entry per reclaimed token. Calling s.deleteLocked(k) instead should be all that's needed.

6. A freshly created counter is born stale, so a live reservation can be evicted. token/services/selector/sherdlock/bounded_locker.go:133. counter() returns new(txLockCounter) with lastLock == 0, and Lock only calls c.touch(time.Now()) at line 74 — after the CAS reservation loop. staleAt(cutoff) is lastLock.Load() < cutoff.UnixNano(), which is true for zero. If Manager.runCleanupTick runs EvictStaleTxState in the window between LoadOrStore and touch, CompareAndDelete removes the counter the reservation was just made on, and the next Lock for that tx creates a fresh one — the transaction gets a full maxLocksPerTx budget on top of the locks it already holds. The comment at 118-120 says this race is prevented, but staleness is judged only on lastLock. Setting lastLock at construction (or before the CAS loop) closes it. Narrow window, but a one-line fix.

Non-blocking, worth a look

  • sherdlock/selector.go:316 — zero-valued MaxTokensPerSelection / MaxLockAttempts abort every selection on the first token, so NewManager(&Config{Fetcher: f, Locker: l, Precision: p}) is unusable. withSelectionTimeout (362-373) has a comment explaining why an omitted zero value has to stay harmless; suggest giving the two new int limits the same treatment and reading <= 0 as unlimited.
  • config/driver.go:116 — dropping defaultNumRetries = 3 makes GetNumRetries() fall back to GetLimits().MaxRetries (10), which derives SelectionTimeout = 10*5s + 30s = 80s. Every deployment that never set numRetries moves from ~15s to ~80s worst-case selection latency. If that's intended, it's worth a line in the docs diff.
  • sherdlock/service.go:50 and simple/service.go:62 — on Validate() failure the code does cfg = &config.Config{}, which discards retryInterval, leaseExpiry, the fetcher-cache settings and every rateLimit* field (since loader.limiter is built from cfg on the next line). One mistyped limit silently turns off per-wallet rate limiting. Resetting just cfg.Limits would match the stated intent.
  1. config.Validate() now checks resolved GetLimits() values instead of raw c.Limits.*, so partially-specified configs (e.g. maxLockAttempts=100 with maxTokensPerSelection unset) are rejected. Added two test cases for the holes the reviewer identified.
  2. Reclaim path in simple/inmemory/locker.go uses deleteLocked() so the previous holder's txLocks count is decremented.
  3. boundedLocker.counter() stamps lastLock at construction, closing the EvictStaleTxState race.
  • NB1 sherdlock selector treats non-positive maxTokensPerSelection/maxLockAttempts as unlimited.
  • NB3 Both selector services reset only cfg.Limits on Validate() failure, preserving rateLimit*/retryInterval/etc.
  • NB2 Docs: corrected stale maxRetries default (3→10) + migration note on the latency change.

@adecaro adecaro 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.

Review: does this fully address #1641?

@HayimShaul

This is a much stronger patch than the version I (and others) reviewed earlier — approve, with two should-fix comments.

Checked out this branch at 14d0930 and verified:

go build ./...                                              # clean
go vet ./token/...                                           # clean
go build ./integration/...                                   # clean
go test -count=1 ./token/services/selector/...                # pass
go test -count=1 ./token/services/storage/db/sql/common/...   # pass
go test -count=1 ./token/...                                  # pass (full tree)
grep -rn "fmt.Errorf" token/services/selector/                # no matches

I re-checked every issue raised in the prior review rounds against this commit rather than trusting
the PR description. Thirteen of them are genuinely fixed, not just superficially patched — the
O(n²) lock counting is now O(1) via shard.txLocks, the Close()/iterator race is closed with a
drain-lock (selectMu), the timeout is a distinct sentinel instead of being conflated with
"sufficient but locked funds," Validate() now checks resolved rather than raw config values so a
partially-specified limits block can't sneak through, and the bounded locker finally has a test
against the real SQL-backed store instead of only a fake.

CI is fully green, including utest (unit-tests-race) which actually ran for 28 minutes and
passed (not just compiled), and every itest leg across dlog/fabtoken/fabricx/dloghsm/interop/
update/dvp/nft.

What it fixes

# Finding Verified
1 Limit silently dropped on default (sherdlock) driver Now enforced in-process via tokensIterated/lockAttempts counters in selectInternal — but see Blocking-adjacent note below, the DB query itself is still unbounded for this driver
2 O(n²) lock counting in inmemory locker shard.txLocks map[string]int, O(1)
3 Race between Close() and in-flight selection selectMu sync.RWMutex drain lock
4 Timeout conflated with locked-funds error Distinct token.SelectorTimedOut sentinel
5 fmt.Errorf usage Removed, confirmed via grep
6 Scattered constructor config Consolidated into sherdlock.Config
7 Rate-limit denial mishandled by selector Hard-stops via errors.Is(lockErr, token.SelectorRateLimited)
8 Validate() checked raw config Now validates GetLimits() (resolved) values
9 Bounded locker only tested against a fake New bounded_locker_sql_test.go against real SQL store

All nine rows above check out against the current code, not just the diff.

Should fix 1 — security doc overstates DB-level enforcement for the default driver

docs/security/selector_resource_limits.md:50-57 says the token-iteration limit is enforced "at
two levels," including a LIMIT ? SQL clause, and claims ~10x faster queries as a result. That's
accurate for the simple driver (simple/selector.go:144 does pass the limit into the query), but
it's false for sherdlock — the default driver. All three sherdlock fetchers document that they
ignore the limit parameter (fetcher.go:106-107, 140-141, 339-340), and cachedFetcher.update()
does an unfiltered SpendableTokensIteratorBy(ctx, "", "") full-table scan on every cache refresh
(fetcher.go:260). maxTokensPerSelection only bounds the in-process counting loop over whatever
that unbounded fetch already returned — it doesn't stop a wallet with millions of tokens from being
pulled into memory in the first place, which is exactly the attack this doc's own threat model
section describes.

Should fix 2 — sherdlock's new abort logic has zero direct test coverage

selectInternal's two new limit checks (selector.go:320-334) aren't exercised by any test —
grep -rn "exceeded max token iteration\|exceeded max lock attempts" token/services/selector/sherdlock/
matches only the production file. manager_unit_test.go:33 wires MaxTokensPerSelection into a
Config but never drives a selection past it. The simple driver has exactly this coverage
(simple/selector_limits_test.go's TestSelector_TokenIterationLimit/TestSelector_LockAttemptLimit),
but sherdlock's cache-swap-on-retry path (selector.go:301-309) is different enough that passing
simple's tests says nothing about sherdlock's behavior here.

Non-blocking

  • Doc nit: the same file's YAML examples and prose (lines 158, 186, 200, 213, 226) use a key
    maxRetryCycles that doesn't exist — the actual field is maxRetries. Config unmarshalling
    silently ignores unknown keys, so anyone copying these examples gets the default retry count
    regardless of what they set.
  • Doc nit: config.Config (driver.go:55) is missing a Godoc comment; the Limits struct
    right above it has one.

Recommendation

Good to merge once the security doc is corrected to scope the DB-level LIMIT claim to the simple
driver (or the claim is made accurate for sherdlock too) — that's the one thing here with a real
chance of misleading an operator's security posture. The test-coverage gap for sherdlock's abort
paths is worth a fast follow if not blocking. Everything else checks out.

Comment thread docs/security/selector_resource_limits.md Outdated
Comment thread docs/security/selector_resource_limits.md Outdated
Comment thread token/services/selector/sherdlock/selector.go
Comment thread token/services/selector/config/driver.go
Bound the work a single token selection can do, so one request can no longer
scan or lock an unbounded portion of the wallet.

Selection limits:

- Add maxTokensPerSelection and maxLockAttempts to the selector config, applied
  per retry cycle rather than cumulatively, and push the row limit down into the
  UnspentTokensIteratorBy query so the database stops producing rows once the
  budget is reached.
- config.Validate() checks the resolved limits from GetLimits() instead of the
  raw c.Limits.* fields, so a partial config (e.g. maxLockAttempts set while
  maxTokensPerSelection keeps its 10000 default) is rejected up front instead of
  aborting every selection at runtime.
- A non-positive maxTokensPerSelection / maxLockAttempts means "unlimited",
  matching withSelectionTimeout, so a Config built without these limits is
  usable. Likewise a non-positive selectionTimeout means "no timeout" rather
  than an already-expired context, and the default is derived so it outlasts the
  default retry budget.
- On Validate() failure, reset only cfg.Limits rather than discarding the whole
  config, so one mistyped limit no longer silently drops retryInterval,
  leaseExpiry, fetcher-cache and rateLimit* settings.

Query correctness:

- The limited query appended a literal "?" placeholder while every other
  parameter is emitted as $N by the query builder — a syntax error on
  PostgreSQL (SQLSTATE 42601). Since simple/selector.go always passes a
  non-zero limit, every token selection failed on a Postgres-backed node. Build
  the LIMIT through the builder instead. It only worked on SQLite because SQLite
  assigns $1-style names sequential indices.
- The row limit was applied before the Go-side dedup, and a directly-owned token
  matches both UNION ALL branches, so a limit of N surfaced roughly N/2 distinct
  tokens and the selector read that as an empty wallet. Use UNION on the limited
  path so LIMIT counts distinct rows.

Contention and locking:

- StubbornSelector.Select releases its partial locks before backing off again.
  Without that, two selections each holding part of the funds both exhausted
  their retry budget and both reported insufficient funds while funds were
  available; it also makes the surrounding log message true again.
- Report the in-memory locker's per-transaction lock ceiling as
  SelectorRateLimited, so the selection fails fast instead of looking like
  contention. A full page with insufficient funds reports the limit instead of
  retrying a query that cannot change.
- Lock cleanup no longer resets live per-transaction counters (which made the
  ceiling per-tick rather than per-transaction), counters are reclaimed on every
  replica instead of only the cleanup leader, and the reclaim path uses
  deleteLocked() so the previous holder's txLocks counter is decremented — it
  previously leaked a dead entry per reclaimed token on a hot shard.
- sherdlock boundedLocker.counter() stamps lastLock at construction so a fresh
  counter is not born stale, closing the race where a concurrent
  EvictStaleTxState between the reservation and the first touch() could evict a
  live reservation and hand the tx a fresh budget.
- Restore the swapCache helper that closes the iterator it displaces; the
  selectInternal rewrite replaced s.cache on every immediate retry and leaked a
  database cursor and its pooled connection per retry.
- Guard the selectMu drain on the default path: StubbornSelector overrides
  Select, so Close() could otherwise close the iterator mid-iteration.

Integration tests:

- Restore the 3-second readiness wait in fabricxTestSuite.Setup(), mirroring
  integration.TestSuite.Setup(). The custom suite introduced in 1c1607c
  dropped it, so BeforeEach began running the issue view before the issuer
  node's view client was up and public params were installed. That produced
  "cannot retrieve public params for [default,testchannel,token_chaincode]" in
  the issue view, and the fire-and-forget InstallPublicParams goroutine then hit
  the shutting-down connection and panicked, killing the whole test binary.
- Relax the CHF1 contention test's retry budget. It spins up 300 concurrent
  goroutines (3 replicas x 100 requests) contending for 2 tokens; the herd took
  ~18s to drain on a slow CI runner while each goroutine had only ~10 backoff
  cycles (~12s), so a few aborted with SelectorInsufficientFunds even though
  funds were available.
- Keep lib-p2p-bootstrap-node out of TMS membership in the fungible topology, as
  the nft/interop/mixed topologies already do.

Also adds regression coverage for each fixed path, including a real-database
exercise of the limited query on both SQLite and PostgreSQL; corrects the stale
maxRetries default in the docs (3 -> 10) with a migration note on the resulting
worst-case selection-latency change; and reformats two files with gofmt 1.27,
which no longer over-indents composite literals in a multi-value return (go.mod
now requires go 1.27.1, so CI resolves that gofmt).

Signed-off-by: Hayim.Shaul@ibm.com <hayimsha@fhe03.vpc.cloud9.ibm.com>
Signed-off-by: Effi-S <effi.szt@gmail.com>
Signed-off-by: AkramBitar <akram@il.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bound All Work Triggered by a Single Token Selection Request [HIGH]

4 participants