Skip to content

tokens: eliminate commit-path cache barriers, struct copying, and unguarded log allocations - #2364

Open
SurbhiAgarwal1 wants to merge 3 commits into
LFDT-Panurus:mainfrom
SurbhiAgarwal1:perf/2188-commit-path-optimizations
Open

SurbhiAgarwal1 wants to merge 3 commits into
LFDT-Panurus:mainfrom
SurbhiAgarwal1:perf/2188-commit-path-optimizations

Conversation

@SurbhiAgarwal1

Copy link
Copy Markdown
Contributor

Summary

Optimizes performance and memory allocations on the transaction commit path in token/services/tokens and token/services/utils/cache:

  • Cache Barrier Removal (ristretto.go): Removed synchronous c.cache.Wait() calls from write paths (Add/Delete/Clear), unlocking asynchronous Ristretto cache throughput. Exposed explicit Wait() helper for testing.
  • Pointer Pass Optimization (storage.go & tokens.go): Changed AppendToken parameter to pointer (*TokenToAppend) and updated CacheEntry.ToAppend, getActions, extractActions, and Parse to pass []*TokenToAppend, eliminating 13-field struct copying (~200+ bytes per output).
  • Event Buffer Capacity Retention (storage.go): Reset event buffer via t.pending = t.pending[:0] in FlushEvents and Rollback to retain allocated backing array capacity across transactions.
  • Upfront Slice Preallocation (tokens.go): Preallocated capacities for toSpend, toAppend, and toDelete slices.
  • Stack Trace Overhead Elimination (tokens.go): Guarded debug.Stack() in Service.DeleteTokens behind logger.IsEnabledFor(zapcore.DebugLevel).
  • Zero-Allocation Variadic Logging (tokens.go & storage.go): Wrapped hot-path DebugfContext log calls with if logger.IsEnabledFor(zapcore.DebugLevel) to eliminate Go variadic []any heap boxing allocations when debug logging is disabled.

Fixes #2188

Copilot AI lite review requested due to automatic review settings September 13, 2026 16:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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

Review of 541179e8c..2d0ca3c53 (the localSession race fix + the tokens commit-path optimizations).

Verified before commenting: go build, go vet and go test are clean on all touched packages, plus -race -count=200 on the new session tests. I could not reproduce a close of closed channel / send on closed channel panic: writeChannel is closed only when closed && inFlight == 0, and inFlight is incremented under the same lock that rejects new sends, so that window is properly closed. Left and right also own disjoint write channels, so cross-side double-close is impossible.

The diff is largely sound. Six findings inline — one medium, the rest low. The medium (session.go Close() asymmetry) is worth addressing before merge; it leaks a goroutine in the same failure class #2285 set out to fix.

return s.readChannel
}

func (s *localSession) Close() {

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.

medium — Close() does not release a peer blocked in send().

Close() is asymmetric: it closes the local write channel, which releases a peer blocked in Receive(), but nothing releases a peer blocked in send(). send selects only on its own closedChan and ctx.Done(), so a peer stuck on a full writeChannel is not woken by the other side closing.

Confirmed with a probe test: fill the 10-slot buffer, block an 11th left.Send, then call right.Close() — the sender stays blocked (only left.Close() or ctx cancellation frees it). Because the closed side's Receive() now returns nil, nothing will ever drain that buffer, so the blocked sender's goroutine leaks permanently — the same failure class #2285 set out to fix.

A close signal shared by the session pair (or selecting on the peer's closedChan) would cover both directions.

return c.cache.Get(key)
}

func (c *ristrettoCache[T]) Add(key string, value T) {

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.

low/medium — dropping Wait() removes read-your-write for new keys.

ristretto's SetWithTTL only applies immediately via storedItems.Update when the key already exists (v2.4.2 cache.go:350); a brand-new key lives only in setBuf until the background processItems goroutine picks it up.

sherdlock's fetcher got a compensating Wait() in updateCache, but the other production consumer — tokens.Service.RequestsCache (manager.go:63) — did not, and the tokens.Cache interface has no Wait. So CacheRequest (tokens.go:190) followed shortly by getActions/GetCachedTokenRequest can now miss and fall back to a full extractActions re-parse, or to a ttxdb read plus ProcessTokenRequest (finality/listener.go:121).

Both fallbacks are correct, so this is not a correctness break — but it silently erodes the very commit-path optimization this commit is about, and the asymmetry with the fetcher looks unintentional.

Comment thread token/services/tokens/storage.go Outdated
func (t *DBTransaction) FlushEvents(ctx context.Context) {
pending := t.pending
t.pending = nil
t.pending = t.pending[:0]

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.

low — pending[:0] aliases the slice being iterated.

pending := t.pending; t.pending = t.pending[:0] makes the slice being iterated alias t.pending's backing array. FSC's simple.EventBus.Publish invokes subscribers synchronously (platform/view/services/events/simple/eventbus.go:30), so any subscriber that reaches back into Notify on the same DBTransaction appends over pending[0] mid-loop: the original event is lost and the newly recorded one is published in its place.

The previous t.pending = nil had no such hazard, and it only costs one allocation per transaction.

Same at storage.go:296 (Rollback), where [:0] additionally keeps the discarded events reachable from the backing array instead of dropping them.


return nil
select {
case s.writeChannel <- msg:

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.

low — send returns nil after the peer closed, silently dropping the message.

After the peer has closed, send still returns nil: the local session's closed flag is false and the peer's read buffer has room, so the payload is accepted into a channel whose reader (peer.Receive()) now returns nil and will never drain it.

Concretely, in startLocal (auditor.go:236) cleanupSessions closes left at the end of CollectEndorsementsView while the AuditApproveView goroutine is still running (it does Append + CacheRequest after signAndSendBack); any further right.Send reports success while the message is silently dropped, instead of surfacing "session is closed".

return nil
case <-s.closedChan:
return errors.New("session is closed")
case <-ctx.Done():

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.

low — case <-ctx.Done() races an immediately-ready buffered send.

Go picks uniformly among ready cases, so a Send made with an already-cancelled context but free buffer space delivers ~50% of the time and returns "context cancelled while sending message" the other ~50% — nondeterministic, and a latent flake source.

Attempting a non-blocking send first (select { case ch <- msg: default: }) before falling through to the blocking select would make it deterministic.

// DeleteTokens marks the tokens as spent in the database, attributed to the caller's stack trace.
func (t *Service) DeleteTokens(ctx context.Context, ids ...*token2.ID) (err error) {
return t.DeleteTokensBy(ctx, string(debug.Stack()), ids...)
deletedBy := "Service.DeleteTokens"

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.

low — spent_by records a constant string when debug logging is off.

With debug logging off (the production default), Service.DeleteTokens now writes the literal string "Service.DeleteTokens" into the spent_by column for every token it removes. WhoDeletedTokens therefore returns a constant with no attribution value for tokens deleted via PruneInvalidUnspentTokens or the public DeleteTokens API, and the stored value differs between debug and non-debug deployments.

The commit-path attribution is unaffected — AppendValid still passes the real txID — and integration/token/fungible/tests.go:814 only asserts on that path, so no test breaks.

If the field is meant to be diagnostic, a stable caller identifier would be better than a constant.

@SurbhiAgarwal1
SurbhiAgarwal1 force-pushed the perf/2188-commit-path-optimizations branch from 76ac39e to a914243 Compare September 21, 2026 14:05
…FDT-Panurus#2285

Problem:
info.Closed in localSession was read and written without synchronization, causing a data race under -race during self-auditing flows (where an issuer is also an auditor).
When a session is closed (e.g. via cleanupSessions), the peer sitting in <-session.Receive() was never unblocked and waited out the full DefaultReceiveTimeout (10 seconds).

Solution:
- Added sync.RWMutex, closedChan, inFlight tracking, and writeClosed boolean to localSession.
- Implemented Send, SendWithContext, SendError, SendErrorWithContext, Receive, and Close with proper mutex synchronization.
- Close() safely closes writeChannel when inFlight == 0, releasing peers waiting on <-peer.Receive().
- Removed root-level plan.md artifact.
- Added unit tests in session_test.go covering peer unblocking on close, concurrent usage under -race, and double session closure.

Fixes LFDT-Panurus#2285

Signed-off-by: Surbhi Agarwal <SurbhiAgarwal1@users.noreply.github.com>
Optimize performance and memory allocations on the transaction commit path:
- Remove synchronous c.cache.Wait() calls from Ristretto cache Add/Delete/Clear
- Pass TokenToAppend by pointer (*TokenToAppend) to eliminate struct copying
- Retain event slice capacity across transactions (t.pending[:0])
- Preallocate slice capacities for toSpend, toAppend, and toDelete
- Guard debug.Stack() and DebugfContext calls behind logger level checks

Fixes LFDT-Panurus#2188

Signed-off-by: Surbhi Agarwal <SurbhiAgarwal1@users.noreply.github.com>
…findings

Address review findings on commit path optimizations and localSession:
- Release peer blocked in send() on session Close() via peerClosedChan
- Prevent silent message drops by rejecting send() after peer closed
- Ensure deterministic non-blocking send prior to context cancellation check
- Restore synchronous Wait() in Ristretto cache Add, Delete, and Clear
- Reset pending events slice to nil in DBTransaction FlushEvents and Rollback
- Record caller location via runtime.Caller(1) in DeleteTokens

Signed-off-by: Surbhi Agarwal <SurbhiAgarwal1@users.noreply.github.com>
@AkramBitar
AkramBitar force-pushed the perf/2188-commit-path-optimizations branch from a914243 to a39395e Compare September 22, 2026 15:06

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

4 participants