Summary
A bundle of performance findings on the commit-path of token/services/tokens, each measured with a -benchmem benchmark or an assertion isolating the cost. None change externally observable behavior; all sit on a per-transaction or per-token hot path.
Where
- Synchronous cache barrier.
Ristretto.Add/Delete call c.cache.Wait() after every write, turning an async cache write into a synchronous barrier. Used by CacheRequest/removeCachedTokenRequest on every transaction. (token/services/utils/cache/ristretto.go:66-74)
- N SELECT+UPDATE pairs per spend.
DBTransaction.DeleteTokens loops DeleteToken, issuing a GetToken (SELECT, to source the notification payload) followed by a Delete (UPDATE) per input — 2 statements per spent token even though the underlying store already batches deletes elsewhere. (storage.go:169-177)
- One UPDATE per token instead of a batch.
SetSpendableFlag loops Tx.SetSpendable, one statement per id. (storage.go:262-272)
- Missing preallocation.
Parse's toSpend/toAppend (tokens.go:407, grown via append at :411/:424/:462/:511) and deleteTokens's toDelete (tokens.go:335, grown at :339) are declared as nil slices and grown by repeated append, even though final sizes are knowable upfront from is.Count()/os.Count() and len(tokens) respectively.
debug.Stack() on every delete. Service.DeleteTokens (tokens.go:210-212) captures string(debug.Stack()) unconditionally on every call, purely to build an audit label, regardless of whether it is ever read.
- Unguarded debug logging in hot loops.
deleteTokens (tokens.go:315) formats an entire []*token2.UnspentToken slice with %v via an unconditional logger.DebugfContext call, and its per-token loop (tokens.go:338/:341) does the same per item — none guarded by logger.IsEnabledFor(zapcore.DebugLevel). Parse shows the correct guarded pattern for its more expensive log lines (tokens.go:470, :513) but several of its own per-input/output calls are unguarded (:410, :419, :423, :432, :437). Go boxes the variadic arguments into []any at the call site unconditionally, before DebugfContext is ever entered, so only the guarded form skips that allocation when debug logging is disabled.
- Struct copied by value.
AppendToken takes TokenToAppend by value (a struct with 13 fields), copying it once per output per transaction. (storage.go:180)
Impact
Each of these sits on the per-transaction commit path (AppendValid → AppendToken/DeleteTokens) or the per-transaction caching path (CacheRequest), so their cost is paid once per token per committed transaction across the whole node.
Reproduction
BenchmarkAdd_WithWait-<n> ... allocs/op, ns/op
BenchmarkAdd_WithoutWait-<n> ... (lower ns/op, isolating Wait()'s cost)
BenchmarkDelete_WithWait-<n> ...
BenchmarkDelete_WithoutWait-<n> ...
TestDeleteTokens_TwoStatementsPerInput — PASS (asserts GetTokenCallCount == DeleteCallCount == n, one SELECT + one UPDATE per input)
TestSetSpendableFlag_OneStatementPerToken — PASS (asserts SetSpendableCallCount == n)
BenchmarkParse_Allocations-<n> ... allocs/op
BenchmarkBufferFlush_NilReset-<n> ... (higher allocs/op — regrows from zero capacity every flush)
BenchmarkBufferFlush_SliceReset-<n> ... (lower allocs/op — retains backing array capacity)
BenchmarkDebugStack-<n> ... ns/op, B/op (cost of the debug.Stack() primitive itself)
TestDebugfContext_UnguardedAllocatesEvenWhenDisabled — PASS
unguardedAllocs > guardedAllocs, guardedAllocs < 1.0
(testing.AllocsPerRun over 1000 runs, debug logging disabled — mirrors the
existing TestLoggerDebugAllocs technique in token/services/logging)
BenchmarkAppendToken_ByValueCopy/ByValue-<n> ... B/op
BenchmarkAppendToken_ByValueCopy/ByPointer-<n> ... (lower B/op, isolating the value-copy cost)
(Exact numbers depend on hardware; all benchmarks live in token/services/tokens/tokens_bench_test.go and token/services/utils/cache/ristretto_bench_test.go and can be re-run with go test <pkg> -bench=. -benchmem -run='^$'.)
Severity
Low individually; cumulative cost across a busy node's transaction throughput.
Part of #2112.
Summary
A bundle of performance findings on the commit-path of
token/services/tokens, each measured with a-benchmembenchmark or an assertion isolating the cost. None change externally observable behavior; all sit on a per-transaction or per-token hot path.Where
Ristretto.Add/Deletecallc.cache.Wait()after every write, turning an async cache write into a synchronous barrier. Used byCacheRequest/removeCachedTokenRequeston every transaction. (token/services/utils/cache/ristretto.go:66-74)DBTransaction.DeleteTokensloopsDeleteToken, issuing aGetToken(SELECT, to source the notification payload) followed by aDelete(UPDATE) per input — 2 statements per spent token even though the underlying store already batches deletes elsewhere. (storage.go:169-177)SetSpendableFlagloopsTx.SetSpendable, one statement per id. (storage.go:262-272)Parse'stoSpend/toAppend(tokens.go:407, grown viaappendat:411/:424/:462/:511) anddeleteTokens'stoDelete(tokens.go:335, grown at:339) are declared as nil slices and grown by repeatedappend, even though final sizes are knowable upfront fromis.Count()/os.Count()andlen(tokens)respectively.debug.Stack()on every delete.Service.DeleteTokens(tokens.go:210-212) capturesstring(debug.Stack())unconditionally on every call, purely to build an audit label, regardless of whether it is ever read.deleteTokens(tokens.go:315) formats an entire[]*token2.UnspentTokenslice with%vvia an unconditionallogger.DebugfContextcall, and its per-token loop (tokens.go:338/:341) does the same per item — none guarded bylogger.IsEnabledFor(zapcore.DebugLevel).Parseshows the correct guarded pattern for its more expensive log lines (tokens.go:470,:513) but several of its own per-input/output calls are unguarded (:410,:419,:423,:432,:437). Go boxes the variadic arguments into[]anyat the call site unconditionally, beforeDebugfContextis ever entered, so only the guarded form skips that allocation when debug logging is disabled.AppendTokentakesTokenToAppendby value (a struct with 13 fields), copying it once per output per transaction. (storage.go:180)Impact
Each of these sits on the per-transaction commit path (
AppendValid→AppendToken/DeleteTokens) or the per-transaction caching path (CacheRequest), so their cost is paid once per token per committed transaction across the whole node.Reproduction
(Exact numbers depend on hardware; all benchmarks live in
token/services/tokens/tokens_bench_test.goandtoken/services/utils/cache/ristretto_bench_test.goand can be re-run withgo test <pkg> -bench=. -benchmem -run='^$'.)Severity
Low individually; cumulative cost across a busy node's transaction throughput.
Part of #2112.