Repository navigation
Conversation
467707e to
cccc3d0
Compare
📊 Token Validation BenchmarkComparison of this PR against the base branch. 🟢 improvement · 🔴 regression · ➖ within ±1.0% noise.
|
Not reproduced in following runs.. seems like a blip.. |
991715b to
7a671c7
Compare
AkramBitar
left a comment
There was a problem hiding this comment.
Two items before merge, plus a couple of minor notes.
1. maxScanCandidates cap is missing (must-fix)
The design doc lists maxScanCandidates: 256 as a bound on the pre-search scan, but the code has no such cap. trySingleTokenExactMatch materialises every candidate from the iterator, sorts them, and binary-searches — on every selectInternal call, including all immediate and backoff retries. For a wallet with many dust tokens this is unbounded. Add a guard before the sort:
if len(candidates) > maxScanCandidates {
return nil, nil, false
}with maxScanCandidates defined as a constant alongside maxImmediateRetries.
2. Doc comment missing retry-multiplier note (should-fix)
trySingleTokenExactMatch is called once per selectInternal invocation, and StubbornSelector re-enters selectInternal on every backoff retry. The doc comment should say so, e.g.: "called once per selectInternal invocation; under StubbornSelector this includes each backoff retry."
Minor notes (no change required):
t.Context()requires Go 1.21+ — confirm the module'sgodirective meets that.- The
UnspentTokensIteratorByCallCount() == 1assertion inDisabledByDefaultRunsGreedywill break if the greedy fetch strategy ever changes; the meaningful invariant ("pre-search did not add a call") is already covered by theWithExactMatchdisabled case — consider relaxing or dropping the count check.
d91a5cb to
29fc311
Compare
|
@AkramBitar, ready for re-review
|
There was a problem hiding this comment.
Reviewed at 43df02d7. go vet and go test ./token/services/selector/sherdlock/... ./token/services/ttx/dep/wrapper/... pass on the branch.
The feature works, but it is unreachable in production and it quietly corrupts the metrics it ships with. Two blockers, both small:
1. WithExactMatch has no production wiring. manager.go:58 calls NewSherdSelector(...) with no opts, and there is no config key, SDK/dig plumbing, or non-test caller anywhere in the tree. s.exactMatch is therefore always false in a deployed node: the three new counters are permanently 0, and docs/development/metrics.md:197-202 + testdata/metrics.golden advertise metrics that can never move. Either add the config/plumbing here, or state in the docs that the pre-search is not yet reachable.
2. The pre-search sits outside the existing metrics/logging contract established in #2395.
selector.go:219—trySingleTokenExactMatchis not passed the caller-ownedattemptedset and never adds to it, soSelect's deferredobserveDistinctTokensAttemptedis skipped whenattempted.Length() == 0. Every change-free selection vanishes from..._distinct_tokens_attempted, and on a miss the pre-search's lock attempts are undercounted — contradicting the documented contract ("distinct tokens a lock was attempted on (won, lost, or rate-limited) per token selection call").selector.go:390-401— the greedy walk distinguishesdriver.ErrTokenAlreadyLocked(→LockConflicts.Add(1)+ debug) from a real store failure (→WarnfContext). This loop does neither: every non-rate-limit error is logged as"candidate [%s] already locked, trying next"at debug. So (a)lock_conflicts_totalundercounts by roughly half under exact-match contention, and (b) if the token-lock store is down, every candidate fails with a connection error reported as "already locked" at debug — bypassing the warning #2395 added precisely to make that outage visible.
Also worth folding into this PR
selector.go:329— on a miss the pre-search discards a full wallet fetch and the greedy walk's firsts.next()immediately re-fetches, so every miss costs two full wallet scans (two per backoff round underStubbornSelector). With the defaultMixedfetcher that also burnsmaxQueriesBeforeRefreshtwice as fast, doubling background full-tableupdate()refreshes and double-countingUnspentTokensInvocations. The candidate list is already in memory — feeding it tos.cacheviaswapCacheon a miss removes the second fetch.selector_exact_match_test.go:160— the branch the shuffle exists for (candidate A lost, candidate B acquired, still change-free) has no coverage:MultipleExactCandidatesSelectOnestubsTryLockReturns(true, nil)so the first candidate always wins, and the only test with a failing exact lock has a single candidate. None of the three new counters are asserted either, and they can't be with the current helper —setupMetricsMocksreturns one sharedFakeCounter, so asetupNamedCounterMocks(analogous tosetupNamedHistogramMocks) is needed first.
Minor
metrics.go:41/metrics.md:200—attemptsis documented as "Select() calls that ran the pre-search" but increments per retry (selectInternalruns once per backoff round, as the doc comment atselector.go:310correctly notes).hits / attemptsis inflated by up tomaxRetriesAfterBackoff; reword to "pre-searches run", or move the increment intoSelect.selector.go:360—maxScanCandidates = 256disables the feature for exactly the wallets it exists for (a wallet with 500 accumulated change outputs keeps minting change forever). The stated justification doesn't hold for thelazy/mixedfetchers:lazyFetcher.UnspentTokensIteratorByalready callscollections.NewPermutatedIterator, which materialises and permutes the whole wallet before the pre-search sees a token — so the cap only avoids an O(n log n) sort over an already-in-memory slice. Consider capping the sort rather than the feature (a single linear pass for an exact match is O(n) and needs no cap). Also, the comment atselector.go:33-34says "unlocked candidates", but the iterator yields tokens locked by other processes too, so a wallet of 300 tokens with 290 held by others still bails.selector.go:217— the pre-search checksisClosed()only atselectInternalentry, while the greedy walk re-checks on everys.next(). IfManager.Close(txID)wins that race the pre-search can still acquire a lock and return a successful selection for a selector already removed fromselectorCache— a third outcomeTestSelectorCloseDuringRetryIsRaceFree's invariant did not previously admit, leaving the lock to the expiry cleaner.selector.go:392— speculative pre-search locks spend the per-wallet lock budget the greedy walk then needs. TheLockercontract (interfaces.go:73-80) permits per-Lockrate limiting, andselector.go:200treatsSelectorRateLimitedas a hard abort; with a burst of 3 and three contended exact candidates, the pre-search can exhaust the budget and fail a request that would previously have succeeded with 60+40. Latent today (the shipped limiter is atSelectlevel), but worth a note.
The token/services/ttx/dep/wrapper/dbs_test.go hunk is a clean improvement — d.batcher.window is set before the goroutines spawn, the helpers already exist in status_batcher_test.go, and it removes a genuine wall-clock flake. No issues there.
a95a530 to
ff96260
Compare
|
@AkramBitar, I'd like to align with you before finishing off the rest of the findings. Please review that this is acceptable and I'll implement the rest of the selector changes. |
Signed-off-by: Effi-S <effi.szt@gmail.com>
Fixes #2404
Part: K=1
Summary
Design proposal for reducing unnecessary change outputs in token selection by preferring exact-sum combinations of available tokens over the current greedy first-fit walk. Follows up on the lock-contention work in #2395 (PRs #2397-#2403) and the amount-blind-selection tracking issue #2017 — this is scoped specifically to change avoidance, not full amount-aware/input-minimizing selection.
This has already been through two rounds of independent adversarial review (checked against the actual
sherdlockselector code, the real batched Postgres locking path, and the fetcher/cache implementation) — the design below reflects both rounds of fixes, not a first draft.Design: Exact-Amount Token Selection (Change Avoidance)
Problem
Selector.selectInternal(token/services/selector/sherdlock/selector.go) is a greedyfirst-fit: it walks candidates ascending by amount (same-amount runs shuffled, per #2399)
and stops as soon as the running sum reaches the requested quantity. It never looks for a
subset of candidates that sums to exactly the requested quantity — it stops at the first
candidate that tips the sum over the target, even when a different, still-unpicked candidate
would have completed the request with zero remainder.
Example: wallet holds
[30, 40, 70, 200](same currency), request is100. Greedy walk:30 → sum 30,40 → sum 70,70 → sum 140 ≥ 100, stop. Result: 3 inputs, sum 140, and thetransaction must produce a change output of 40 back to the same wallet. But
30 + 70 = 100was available the whole time: 2 inputs, no change. Note this combination is notreachable by checking only "is there a completing candidate for whatever gap remains once the
greedy walk is about to overshoot" — by the time the walk is about to overshoot (after
30and
40are already committed to the window), the30token is no longer available to pairwith
70. Finding30 + 70requires looking at combinations before committing to the greedyorder, not patching the greedy order at its last step.
Every unnecessary change output is a new token the wallet immediately has to track, fund a
future lock cycle for, and — per #2395 — a new opportunity for lock contention. Reducing
change outputs is therefore not just a UX/fee concern; it also shrinks the token population
that PRs #2398–#2403 are protecting from collisions.
Goals / non-goals
Goal: when an exact-sum combination of available (unlocked) candidates exists, prefer it
over a combination that overshoots and requires change — without regressing anything #2395's
stack already fixed, and in particular without adding a per-selection database round trip
beyond what the batched Postgres path (#2402) already does.
Non-goals:
denial-of-service surface (a request can be crafted, or simply occur naturally under load,
that makes selection scan or backtrack arbitrarily). The search below is bounded in both
input count (
k) and candidates scanned, and falls back to the existing greedy behaviour onany budget exhaustion or outright miss.
ordering exists specifically to spread contention across same-amount candidates; the fallback
greedy walk is untouched by this proposal.
sherdlockonly.simplehas no ordered, windowed fetcher and no anti-join —building a bounded search on top of it would mean adding most of fix(selector): anti-join locked tokens and order candidates by amount #2399's machinery to a
driver whose whole point is to stay minimal. If exact-match selection is wanted under
simplelater, that is a separate proposal.Design
Core idea: a bounded pre-search over the cache, run before greedy accumulation starts
The mechanism is a distinct step that runs before
selectInternal's existing window-growthloop (
sherdlock/selector.go:264-351) is entered for a givenremaining = quantity - sum, nota patch applied inside that loop:
kunlocked, not-yet-blacklisted candidates inthe fetcher's already-loaded cache for this wallet+currency whose amounts sum to exactly
remaining. This never callss.next()and never touches the iterator cursor — it reads asnapshot of the cache's sorted-by-amount data (see Fetcher/cache interaction).
windowdirectly — the existing window-growth loop isskipped entirely for this attempt — and is claimed through the exact same single
TryLockBatchcall, with the exact same win/loss handling (sherdlock/selector.go:301-351)that already exists today. No new locking logic, no new race handling: the win/loss/blacklist
code at lines 322-336 already tolerates a window where not every entry wins the race, so a
partially-contended exact-sum window degrades exactly the way a partially-contended greedy
window does today — the losers are blacklisted,
sumincludes only the winners, and theouter loop continues (which will re-run the pre-search against the new, smaller
remainingbefore falling back to greedy again).
loop. Because the pre-search never consumed anything from the iterator, nothing is displaced,
discarded, or double-counted — the greedy walk starts exactly where it would have without this
feature.
Running the search before any greedy commitment, over the full cached candidate set rather
than a shrinking suffix, is what makes
30 + 70 = 100reachable: both tokens are still"available" from the pre-search's point of view, because neither has been swept into a window
yet.
Algorithm
Given
remaining = quantity - sumand the cache's ascending-sorted candidate list for thiswallet+currency (minus already-blacklisted/locked entries):
k = 1(single completing token): binary search the sorted cache foramount == remaining.O(log n).k = 2(a completing pair): a standard two-pointer scan over the sorted cache —lo = 0,hi = n-1; whilelo < hi: ifcache[lo]+cache[hi] == remaining, record the pair; if< remaining,lo++; if>,hi--.O(n)in the cache size for this wallet+currency, one pass,no nested loop and no per-candidate point-query — this replaces the first revision's "for each
candidate
a, look upremaining - a" sketch, which could not be bounded cleanly against apoint-query budget. Continue the scan (bounded by
maxTieBreakCandidates, default 4) to collecta handful of valid pairs rather than stopping at the first, then shuffle among them before
picking one — the same anti-hotspot rationale as fix(selector): anti-join locked tokens and order candidates by amount #2399's bucket shuffle, now stated as an
actual rule instead of an open question.
k > 2: not proposed. Cost grows combinatorially and the marginal benefit overk = 1..2is expected to shrink; left as a future, separately-justified change if data says otherwise.
Recommended default:
k = 1first, since it is the cheapest and covers the commonsingle-completing-token case (including cases the ascending-order fix in #2399 does not
already cover — see the worked example above).
k = 2is the next increment, costed andbounded above rather than left vague.
Point-query fallback, and why it is now a backstop rather than the common path. The pre-search
above assumes the cache holds the wallet+currency's full spendable set, not a small lookahead
window. That is in fact how the cache is populated:
cachedFetcher.update()loads via anunfiltered
SpendableTokensIteratorBycall and buckets the entire result by wallet+currencykey, with no per-key truncation — so for the wired fetcher configuration, the pre-search
ordinarily has the complete candidate set to work with already, in memory, with no additional
query. A point-query fallback (
amount = target_gap, same anti-join predicate asbuildSpendableTokensIteratorByQuery,LIMITa small constant) is kept only as a backstop forthe case the in-cache search comes up empty because the cache is momentarily stale relative to
the DB (a token created after the last refresh) — capped at
maxPointQueries(default: 1) perSelect()call, and only ever attempted fork = 1(thek = 2two-pointer scan has noper-candidate point-query fallback at all, by design — see Budgets below).
Costs and tradeoffs
TokenStore.GetSchema(
token/services/storage/db/sql/common/tokens.go) defines five indexes onTokens, none ofwhich reference
amount; theORDER BY amountadded in fix(selector): anti-join locked tokens and order candidates by amount #2399 is an unindexed sort today,affordable only because per-wallet candidate sets are small. The point-query backstop above
needs a composite index, e.g.
(owner_wallet_id, token_type, amount), matching the existingpartial-index style already used for
idx_owner_wallet_part, to stay a cheap lookup ratherthan a sequential scan. This index has a write-path cost — one more B-tree entry maintained on
every token creation and consumption — that has to be weighed against the read-side benefit in
the benchmark below; this document does not yet have production insert/delete-rate numbers to
quantify that tradeoff and treats it as an open rollout question, not a settled one.
call.
TryLockBatch/the PostgresLockBatchimplementation treats its token-ID list as anunordered set — multi-row
INSERT ... ON CONFLICTand theFOR UPDATE SKIP LOCKEDjoinimpose no ordering or contiguity requirement — so handing it a pre-search-selected window
instead of a greedily-grown one is exactly as costly as today's single
TryLockBatchcall:no second lock round trip is introduced. The only added round trip is the point-query
backstop, and it fires only on a stale-cache miss, which should be rare given the cache
contains the full candidate set by construction (see above) — but "should be rare" still needs
the benchmark in Rollout to become "is rare in practice."
maxLookaheadInputs(k) bounds combinatorialdepth; a separate
maxPointQueries(default 1,k = 1only) bounds actual DB round trips;a separate
maxTieBreakCandidates(default 4) bounds how many same-sum pairs thek = 2scancollects before shuffling, all independent of each other so a cheap in-memory scan is never
throttled by a budget meant for expensive round trips.
TryLockBatchcall and existing win/loss/blacklist bookkeeping as any other window; aconcurrent selector finding and racing for the same subset loses exactly the way any other
contended-token race is lost today.
amount == remaining,cache[lo]+cache[hi] == remaining)reuse the same
token2.Quantitytype and arithmetic the existing sum logic already uses(fixed-precision,
NUMERIC(78,0)-backed, not floats), scoped to the same wallet+currency+typecandidate set as the surrounding query — no new precision or multi-currency concern.
Fetcher/cache interaction
cachedFetcher.update()loads the entire spendable-token set (unfiltered by wallet/currency)and shards it into per-wallet+currency slices with no truncation, and
UnspentTokensIteratorByhands back a fresh permutation of that per-key slice. The pre-search reads that same per-key
slice directly — a plain sorted-by-amount view, not the shuffled permutation the greedy walk
uses for iteration order — so it sees the wallet's complete cached candidate set for the
requested currency and never disturbs the iterator the greedy fallback will use if the
pre-search misses. This requires one small, additive fetcher API surface: a way to read the
current cached slice for a wallet+currency without minting a new permutation or advancing any
cursor (e.g. a
PeekSortedCandidatesaccessor alongsideUnspentTokensIteratorBy), which doesnot change the existing iterator's public behaviour.
One asymmetry is worth stating plainly: the point-query backstop reads live from the database,
bypassing the Ristretto cache layer entirely, while the cache the pre-search normally uses may
be serving data up to
fetcherCacheRefreshstale. The backstop is therefore more current thanthe common path, not less — intentional, since it exists specifically to catch what the cache
missed, but worth calling out rather than leaving implicit.
Cost of running the pre-search itself. Because it operates on an in-memory, already-fetched
slice,
k = 1's binary search andk = 2's two-pointer scan are cheap relative to any DBround trip — but not free for a pathological wallet holding a very large number of tokens for
one currency (e.g. many dust outputs), especially since
selectInternal's outer loop can re-runthe pre-search on every immediate retry (up to
maxImmediateRetries = 5). Bound this with amaxScanCandidatescap: if the cached slice exceeds the cap, the pre-search only considers itsmaxScanCandidatessmallest entries (the slice is already sorted ascending, so this is adeterministic truncation, not a random subset) — this only reduces the feature's recall for
unusually large per-currency candidate sets, it never affects correctness, since the fallback
greedy walk still runs when the pre-search doesn't find anything.
Interaction with existing mechanisms
were already filtered by the anti-join at fetch time, and the point-query backstop uses the
same
NOT EXISTSpredicate, so the pre-search never proposes a candidate someone else alreadyholds.
pre-search has its own, analogous shuffle for
k = 2tie-breaking (see Algorithm above);k = 1has no ties to break (only one candidate can equalremaining).race is added to the same per-
Select()blacklist as any other candidate, so a refetch duringthe immediate-retry layer, and the pre-search that runs again after it, won't immediately
re-attempt it.
StubbornSelectorlayer: untouched — a full retry still re-runsselectInternalfrom scratch, re-deriving the pre-search fresh each time.chosen among already-available ones, not the lock lifecycle.
through whichever
lockStrategyis configured, exactly like any other window, and theround-trip analysis above is specifically about not undoing what feat(selector): configurable Postgres lock-acquisition strategies (Phase 6, #2395) #2402 achieves.
Configuration
Extend the existing
token.selectorYAML block (all new, all optional, defaulting topreserving today's behaviour when unset):
simplereads and ignores this block, same pattern aslockStrategytoday.Observability
Add counters alongside the contention metrics already introduced in #2397
(
LockConflicts,DistinctTokensAttempted):ExactMatchAttempts,ExactMatchCacheHits,ExactMatchPointQueryHits,ExactMatchMisses. Splitting cache hits from point-query hits (notjust an aggregate "hit") is what lets the round-trip cost claim above be checked against real
traffic instead of assumed.
Testing
[30,40,70,200]/100example from Problem (must select{30,70}, not{30,40,70}), plus:no completion needed (single candidate already exact),
k=1cache hit,k=1point-querybackstop hit,
k=2pair found with multiple ties (verify shuffling occurs across repeatedruns), a contended pre-search subset that partially loses its race (falls through to a
second, smaller pre-search / greedy pass), no completion exists at any depth (unchanged
greedy result, and identical to today's output),
maxScanCandidates/maxPointQueriesexhausted (falls back without hanging or an extra round trip).
TryLockBatchcall (nottwo) for a cache-hit window — the concrete, testable form of the "zero additional round trips
in the common case" claim.
TestHotTokenContention-style benchmarks (added in test(selector): close lock-contention testable gaps + extend sherdlock benchmarks (Phases 7-8, #2395) #2403) with an "exact-match enabled"variant, measuring
RoundTrips()and cache-hit vs. point-query-hit rates, to confirm the addedcost stays within the claims above under realistic contention.
validated by the token store.
Rollout
exactMatch.enabled: false(default off), scoped tosherdlockonly, includingthe new
PeekSortedCandidates-style fetcher accessor and the(owner_wallet_id, token_type, amount)index.TestHotTokenContentionvariant above, plus a "change-outputrate" measurement against a synthetic wallet population (the metric this feature is meant to
move), measuring both the read-side round-trip cost and the write-side index-maintenance cost,
before flipping the default.
trueand revisit whetherk = 2is worth enabling by default given its extra scan/tie-break cost — a separate, data-drivendecision, not part of this initial rollout.