Skip to content

tokens: commit-path performance bundle — synchronous cache barrier, N+1 statements per transaction, missing preallocation, debug.Stack() per delete, unguarded debug logging, by-value struct copy #2188

Description

@adecaro

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 (AppendValidAppendToken/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.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions