Skip to content

Feat/fsmhooks - #489

Closed
ezeike wants to merge 28 commits into
mainfrom
feat/fsmhooks
Closed

Feat/fsmhooks#489
ezeike wants to merge 28 commits into
mainfrom
feat/fsmhooks

Conversation

@ezeike

@ezeike ezeike commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

No description provided.

ezeike and others added 28 commits August 4, 2026 04:46
changedBlobKeys diffed accounts/pools/validators by building a
map[string][]byte per side and comparing via map lookups - two full
hash-map builds per indexer-blobs request at ~1.35M accounts.

Add mergeChangedBlobKeys, a two-pointer merge walk over the already
Pebble-sorted entry slices, and use it for accounts and validators,
whose extracted keys (account/validator address bytes) match their
Pebble storage-key order exactly.

Pools stay on the map-based diff: poolEntryKey extracts Pool.Id's raw
varint wire bytes, which diverges from KeyForPool's big-endian storage
encoding at varint length boundaries (e.g. id 16383 vs 16384), so entry
order there does not reliably match key order.

Adds TestMergeChangedBlobKeys_MatchesMapBasedDiff, which asserts the
merge-walk output equals the original map-based diff output on the
same scenario, plus an empty-previous edge case.
cold_read_time was running ~3s higher than what current+previous IndexerBlob's
own instrumented steps accounted for (even after weighting accounts_iterate/
validators_iterate/block_non_signers_get by the previous-reuse miss rate) --
DeltaIndexerBlobs (parsing every account/pool/validator entry into a map on
both sides to diff them) and the final lib.Marshal call were both inside the
coldStart timing window but had no ObserveIndexerBlobStep of their own,
making them an invisible chunk of the total.

Adds delta_compute and delta_marshal steps to canopy_indexer_blob_step_time
so that gap is attributable instead of inferred.
Compact() shared one hardcoded 3-minute context timeout between two very
different callers: MaybeCompact() (live, runs on every commit) and
CompactAll() (one-time, background goroutine right after a node finishes
syncing/restoring, covering all four prefixes including the indexer
prefix - every block/tx/QC in chain history, never touched during
normal operation).

At 774k+ blocks the indexer prefix alone exceeds 3 minutes to compact,
so CompactAll always failed outright on [i/] with 'context deadline
exceeded' (observed on localnet-2: [s/] and [h/] compacted fine in
1m08s/1m47s, [i/] hit the timeout at exactly 3:00). Because CompactAll
aborts on first error, the indexer prefix has effectively never
finished a post-sync compaction, consistent with the store sitting at
~259GB across 2400+ SST files days after the fact.

Give MaybeCompact's LSS/HSS calls (still time-sensitive, live path)
liveCompactionTimeout (3m, unchanged) and CompactAll's calls
postSyncCompactionTimeout (30m) - CompactAll runs off the hot path in
a background goroutine, so a longer bound costs nothing.

Adds TestCompactionTimeoutsDistinct (guards against the two budgets
being collapsed back into one shared constant) and
TestCompactAllSucceedsOnAllPrefixes (smoke test that every prefix,
including indexerPrefix, compacts through the new signature).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ndexerBlob

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eCollector

Also fixes entryFor()'s address extraction: KeyForAccount() encodes the
address as a length-prefixed segment (lib.JoinLenPrefix), so stripping
only AccountPrefix() left a stray length byte on the front of
AccountChangeEntry.Address. Updated account_change_test.go's key
fixtures to build keys via the real KeyForAccount() (previously hand-
rolled AccountPrefix()+raw bytes, which masked the bug).
Remove indexerBlobCacheEntries and serveIndexerBlobsLive from docker config
fixtures — they are intentionally partial and overlay on defaults via
json.Unmarshal. These edits were unnecessary and introduced an undisclosed
unrelated field (indexerBlobCacheEntries).
Adds a skipAccounts bool parameter to StateMachine.IndexerBlob so
callers that source account deltas separately (IndexerBlobsCached,
Task 9) can skip the expensive full IterateAndAppendWithThreshold
(AccountPrefix()) scan over ~1.35M accounts. IndexerBlobs (plural)
keeps full-scan behavior, passing false at both call sites.

Also updates cmd/rpc/query.go's two IndexerBlob call sites to pass
false, preserving today's behavior and keeping the repo building -
those flip to true and wire the fast path in Task 9.
…in the account fast path

The collector only reports accounts the block WROTE, but a reward or slash
usually moves stake instead: a compounding validator's reward is routed to
UpdateValidatorStake, a non-compounding validator's reward credits
validator.Output rather than the operator address the event names, and a slash
touches stake only. The old full-scan path force-included those accounts on both
sides because its maps held every account in state, so the fast path was
emitting a strictly smaller Accounts set on nearly every block paying or
slashing a validator. Read the missing reward/slash addresses back individually
-- O(block events), not O(all accounts).

Also: emit each side sorted by address, since Results() ranges a map and
deltaBytes is cached and served; copy account bytes so the response never
aliases the shared tip cache; and fall back to the full scan at height 2, where
there is no previous blob and the full account set is the documented output.
…es full-scan-and-diff

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds an integration-level test proving AccountChangeCollector captures only
account-prefixed keys when a real applied block also writes POOL and VALIDATOR
entries via BeginBlock/EndBlock committee-reward machinery (not a direct Set
call, which TestStateMachine_SetDoesNotHookNonAccountKeys already covers).

newTestPoolAndValidatorTouchingChain extends the newTestAccountDeltaChain
pattern: validator #3 (the certificate's reward recipient) is flipped to
Compound=true before block 4 commits, so block 5's committee reward routes
through UpdateValidatorStake/SetValidator instead of the default
non-compounding AccountAdd-to-output path. The unconditional DAO pool mint
in BeginBlock supplies the pool write.

The test verifies -- rather than assumes -- that the measured block writes
both a pool and a validator entry (strict pre/post balance increases), then
asserts every captured account entry is address-sized, is not validator #3's
(proven-written) address, and round-trips to the live KeyForAccount value.
Verified the validator-address check specifically catches a
bytes.HasPrefix(k, ValidatorPrefix()) regression by temporarily broadening
the collector hook and confirming the test fails, then reverting.

Per re-scoping from the task-11 brief: the brief's other proposed test
(reward/slash capture proving force-include redundancy) is intentionally
NOT implemented -- that premise was found false by review; force-include
remains necessary and is already covered by
TestIndexerBlobsCached_ForceIncludesUnwrittenRewardSlashAccounts
(cmd/rpc/query_test.go) and TestAccountDelta_MatchesOldFullScanAndDiff
(fsm/indexer_test.go, Task 10).
MUST-FIX: bounds guard in AccountChangeCollector.entryFor before slicing
the address out of a too-short account-prefixed key. This runs on the
consensus write path (StateMachine.Set/Delete return this error before
performing the store write), so a panic here would abort ApplyBlock and
take the whole block commit down, not just fail indexing. Returns
fsm.ErrInvalidKey (the existing constructor fsm/key.go already uses for
malformed storage keys) instead of new error plumbing.

MUST-FIX: gate cmd/rpc IndexerBlobsCached's account-delta fast path on
ServeIndexerBlobsLive (previously only live capture read this flag), so
disabling it is a config-only way back to the full-scan-and-diff path
without a redeploy if GetAccountDelta misbehaves in production -- a
failure there now fails the whole indexer response, not just accounts.
Also fixes the previous blob's hardcoded skipAccounts=true, which was
only equivalent to the fast path's predicate before this flag existed.

RECOMMENDED: document the QC-normalization invariant GetAccountDelta's
RootDexBatch restore depends on (controller/account_delta.go);
save/restore ApplyBlock's accountCollector instead of unconditionally
nil'ing it, so nested/reentrant ApplyBlock calls can't clobber an outer
collector; deprecate the orphaned full-scan (*StateMachine).IndexerBlobs
wrapper so a future caller doesn't silently reintroduce the regression
this branch removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-poison AccountChangeCollector instead of returning errors from
RecordSet/RecordDelete: an internal failure (a baseline read error, a
malformed key) now disables collection for the rest of the block
without ever aborting the StateMachine.Set/Delete call it hooks, so a
bug in the indexing hook can no longer stop a live block from
committing. Callers check Err() before trusting Results(); a poisoned
collector's delta is dropped rather than cached or served.

Fix GetAccountDelta reading the block and QC through the live
consensus store: every commit closes and swaps the live store's
readers unsynchronized, so a concurrent RPC read raced Store.Reset().
Read through a TimeMachine(height) snapshot instead, matching
IndexerBlob's existing pattern.

Move accountSide/sortedAccountEntries/forceIncludeAccounts out of
cmd/rpc into fsm.AssembleAccountDeltaSides so the differential
regression test exercises the real production code instead of a
hand-duplicated copy that could drift from it unnoticed. Consolidate
the three positionally-threaded added/changed/removed slices into a
single fsm.AccountDelta so they can't be transposed across a call
boundary, and copy its slice headers on a cache hit so a caller's
append can't write through into the shared tip cache. Hoist
AccountPrefix() into a package-level accountKeyPrefix so the
consensus-path hook doesn't allocate on every write.

Log a warning node-side when the fast path fails: the error was
previously only visible in the HTTP 400 body sent to the indexer
client, giving an operator no signal to reach for the
serveIndexerBlobsLive=false kill switch.

Add StateMachine-level tests for the Delete hook (hooked, non-account
key, nil collector) mirroring the existing Set hook coverage.
Add controller/account_delta_replay_test.go: full coverage of
GetAccountDelta's replay branch against a real committed chain --
happy path, the RootDexBatch restore for both a QC carrying a batch
and one with nil Results, a missing block, an above-tip height (the
TimeMachine clamp guard), a missing QC, and the height-2 boundary
where the QC fetch must be skipped entirely.

Add controller/block_test.go: table test for
accountCollectorForLiveCommit over {syncing, ServeIndexerBlobsLive},
and cacheAccountDelta coverage for nil collector, nil cache, a
healthy collector, and a poisoned one -- the live-commit gating that
decides whether the tip cache is ever populated.

Add AccountDeltaTipCacheHits/Misses metrics and a debug log on the
replay fall-through, so a cold-cache period (restart, backfill) is
visible instead of only showing up as an indirect step-time increase.

Extend the fsm differential test with an empty-block case (the only
account movement comes from the automatic reward payout) and cover
AccountEntriesAtVersion's skip-missing-account branch. Tighten
TestApplyBlock_CollectorCapturesTouchedAccounts from a vacuous
NotEmpty check to exact address/value assertions.
@ezeike ezeike closed this Aug 4, 2026
@ezeike
ezeike deleted the feat/fsmhooks branch August 4, 2026 21:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant