Skip to content

feat: head state cache for fast RPC reads on top of head#12150

Closed
LukaszRozmej wants to merge 3 commits into
masterfrom
feature/head-state-cache
Closed

feat: head state cache for fast RPC reads on top of head#12150
LukaszRozmej wants to merge 3 commits into
masterfrom
feature/head-state-cache

Conversation

@LukaszRozmej

@LukaszRozmej LukaszRozmej commented Jun 26, 2026

Copy link
Copy Markdown
Member

Changes

Adds an opt-in, cross-block coherent cache of state at the canonical head (and the last few ancestors, "head-X") to speed up read-only RPC calls on top of the tip — both the execution-path calls (eth_call, eth_estimateGas, trace_*, debug_trace*) and the direct state reads (eth_getBalance, eth_getStorageAt, eth_getTransactionCount, …).

Today the RPC read paths walk the trie on every call (served only by the trie-node cache + RocksDB block cache); there is no direct address→Account / (address,slot)→value cache for RPC. This PR adds one, kept coherent across blocks by applying each block's changed keys.

  • HeadStateCache — two SeqlockCaches (accounts + storage) coherent at head, a head-hash ring + per-block changed-key ring for head-X. Writes (backfill / advance / flush) are serialized under a write lock; reads stay lock-free and are guarded by a generation seqlock (fall back to the trie if the head moves mid-call).
  • HeadStateCacheScopeProvider — decorates the read-only world state for the execution-path RPCs: O(1) head lookups (generation-guarded backfill on miss), head-X served for keys unchanged in the window (changed keys deferred to the trie at the head-X root), pure passthrough otherwise.
  • HeadCachedStateReader — decorates the shared IStateReader so eth_getBalance/eth_getStorageAt/… hit the cache for head/head-X reads.
  • HeadStateCacheUpdater — applies head changes on a single background worker (off the BlockAddedToMain thread): refreshes the keys a block changed, flushes on reorg/gap/self-destruct. Bounded queue; a dropped event self-heals via the next non-sequential flush.
  • HeadStateDeltaCaptureScopeProvider + HeadStateDeltaBuffer — capture each block's changed accounts and storage cells from the processing world state's write batches (works on any node, independent of EIP-7928), keyed by (blockNumber, stateRoot). The consensus / generated Block Access List are fallbacks when no capture is present.
  • Config (IBlocksConfig): EnableHeadStateCache (default false), HeadStateCacheDepth (default 2), cache sizing (clamped to valid ranges).

Scope / cost: when the flag is on, the capture decorator wraps the main processing world state, so each storage write does a HashSet.Add and each block commit a per-block ToFrozenSet — non-trivial but bounded per block. With the flag off (default) there is zero processing overhead. Reads on the RPC path become O(1) hash lookups instead of trie traversals.

Types of changes

  • New feature (a non-breaking change that adds functionality)
  • Optimization

Testing

Requires testing

  • Yes

If yes, did you write tests?

  • Yes

Notes on testing

  • HeadStateCacheTests (State.Test): decorated reads at depths 0..X match undecorated trie reads (changed + unchanged keys, hit + backfill); reorg-flush; passthrough; per-block capture keyed by (number, root); self-destruct → flush; parallel-write capture thread-safety (mirrors the parallel storage-root workers).
  • HeadStateCacheUpdaterTests (Blockchain.Test): journal-delta advance refreshes cached account/slot off-thread; self-destruct flush; non-sequential flush.

Documentation

Requires documentation update

  • Yes

Config reference for the new Blocks.EnableHeadStateCache options (once defaults are finalized).

Requires explanation in Release Notes

  • No

Remarks

Opt-in, default off. Concurrency model (review-hardened): a write lock serializes cache writes so a backfill that races an Advance is dropped rather than published stale; Advance is exception-safe (generation only flips in a no-throw phase); the updater never reads the pooled Block.AccountChanges (TxPool owns/disposes it) — changed accounts come from the journal capture instead.

Remaining follow-ups: hit/miss metrics for observability; coverage for non-standard processing paths (AuRa/Optimism/Taiko — currently they produce no capture delta and fall back to BAL/flush, so they're correct but not journal-accelerated); on-flag processing-overhead benchmark. A documented future phase is reusing the same coherent-delta mechanism to speed up consecutive-block processing (deferred until the cache is proven, since a stale value there is an invalid block, not just a bad RPC response).

🤖 Generated with Claude Code

Adds an opt-in, cross-block coherent cache of state at the canonical head
(and the last few ancestors) to accelerate read-only RPC calls (eth_call,
debug_trace*, trace_*) that execute transactions on top of the tip.

Account and storage reads on the RPC tx-processing path become O(1) lookups
instead of trie traversals. The cache is kept coherent with the head by
applying each block's changed keys (refreshed values), and flushes on reorg.
The per-block changed-storage set is captured from the processing world
state's write batches (works on any node, independent of EIP-7928), with the
consensus/generated Block Access List as fallbacks; changed accounts come
from Block.AccountChanges.

Disabled by default (Blocks.EnableHeadStateCache). RPC-only: block processing
is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@LukaszRozmej

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 6m 21s —— View job


Review: head state cache for fast RPC reads

Nice, well-documented design — the generation-seqlock + per-block changed-key ring is a clean way to get cross-block coherence, and confining it to the shareable RPC source keeps the blast radius small. I focused on correctness/concurrency, the processing hot path, and the coherence invariants. Findings are inline; summary below.

🔴 Critical

  • Use-after-free / data race on block.AccountChangesHeadStateCacheUpdater.TryCollectChangedKeys iterates block.AccountChanges (a pooled ArrayPoolList) that TxPool already owns and disposes on its ProcessNewHeadLoop thread (TxPool.cs:240/290). Both subscribe to the same BlockAddedToMain, with no lifetime coordination — the updater can read a buffer that's being returned to the ArrayPool (and re-rented) concurrently. Fires on essentially every head advance with the flag on. → inline

🟠 High

  • Backfill not fenced against Advance — in CachingScope.Get / CachingStorageTree.Get, the IsCurrent check and the _accounts.Set/storage.Set are not atomic w.r.t. HeadStateCache.Advance. A backfill can publish a stale (old-head) value after Advance refreshed that key, leaving a poisoned entry that's served to all later head readers until evicted. The generation seqlock guards reads but not the backfill write. → inline

🟡 Medium

  • Block-processing hot-path cost + inaccurate "unaffected" claim — with the flag on, HeadStateDeltaCaptureScopeProvider wraps the main processing world state: a HashSet.Add per storage write and a ToFrozenSet per Commit. Across a multi-block sync scope the accumulated set makes each commit O(n²). → inline
  • Captured delta trusted even if incomplete — the journal delta short-circuits the BAL fallbacks, so coherence requires the recorded changed-slot set to be complete on every processing path. Parallel BAL execution and AuRa/Optimism/Taiko are listed as unverified; if any bypasses the intercepted IStorageWriteBatch.Set, an under-inclusive delta is preferred over the complete BAL → wrong RPC reads. → inline
  • HeadStateDeltaBuffer keyed only by post-state root — distinct blocks sharing a state root collide; the wrong block's changed-set can be applied. Rare, but a silent coherency hole. → inline

🔵 Low

  • Unvalidated config can throw from the cache constructors and abort startup with an opaque error (HeadStateCache*SetsBits ∉ [1,20], depth < 0). → inline
  • Cached storage byte[] is shared by reference across readers/backfill — relies on callers never mutating returned storage arrays (standard in the codebase, worth a doc note).

Notes

  • The core ring-alignment and head-X deferral logic looks correct, and the reorg/gap/self-destruct → flush behavior is the right conservative default.
  • Tests cover the happy paths well; as the PR already notes, a concurrency stress test for the generation seqlock (and ideally one for the AccountChanges/TxPool ownership conflict above) would be valuable.
  • All findings are on the opt-in path (default off), so production exposure today is limited — but the Critical and High are correctness/memory-safety issues that should be resolved before the flag is recommended for any real use.

Verdict: not mergeable as-is — the Critical (pooled-buffer race) and High (backfill coherence) should be fixed first, and the Medium findings addressed or explicitly acknowledged.

Comment on lines +131 to +136
private static FrozenSet<AddressAsKey> ToAccountSet(ArrayPoolList<AddressAsKey> addresses)
{
HashSet<AddressAsKey> set = new(addresses.Count);
foreach (AddressAsKey address in addresses.AsSpan()) set.Add(address);
return set.ToFrozenSet();
}

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.

Critical — shared ownership / data race on the pooled block.AccountChanges.

block.AccountChanges is an ArrayPoolList<AddressAsKey> that the TxPool already owns and disposes. TxPool.OnHeadChange enqueues the same BlockReplacementEventArgs to _headBlocksChannel, and ProcessNewHeadLoop (running on a separate task thread) calls block.DisposeAccountChanges() — which returns the backing array to ArrayPool and nulls the field — at TxPool.cs:290 (and synchronously at :240 on the syncing path).

Both TxPool (via ChainHeadInfoProvider.OnHeadChanged) and this updater subscribe to the same IBlockTree.BlockAddedToMain. There is no coordination on the pooled buffer's lifetime, so ToAccountSet(accountChanges) here can iterate AsSpan() over an array that is concurrently being returned to the pool (and potentially re-rented/mutated elsewhere). That is a use-after-free of pooled memory: it can cache/refresh arbitrary addresses (silent state corruption) or fault.

This fires on essentially every canonical head advance when the cache is enabled. It needs an independent source of changed accounts (e.g. copy them out under guaranteed-alive ownership, or derive accounts from the journal-captured delta the same way storage is) rather than borrowing TxPool's pooled list.

Fix this →

Comment on lines +56 to +62
// Miss (or raced an advance): load at the pinned header root and backfill while still current.
Account? loaded = baseScope.Get(address);
if (snapshot.IsCurrent)
{
_accounts.Set(in key, loaded);
}
return loaded;

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.

High — backfill is not fenced against Advance; a stale value can be published into the head-coherent cache and persist.

The IsCurrent check and the _accounts.Set are not atomic w.r.t. HeadStateCache.Advance:

reader:                                     updater (Advance):
loaded = baseScope.Get(addr)  // value @ old head N
if (snapshot.IsCurrent) // true, gen==N
                                            gen -> N+1 (odd)
                                            refresh(addr) -> writes value @ N+1
                                            gen -> N+2 (even)
_accounts.Set(addr, loaded)   // overwrites with stale value @ N

The whole point of the generation seqlock is logical coherence, but the backfill write can land after Advance has already refreshed that key to the new head value, leaving a stale entry that is then served to all subsequent head readers until the key changes again or is evicted. The race window is the (potentially disk-bound) base trie read, and one lost race poisons the entry indefinitely. Same pattern in CachingStorageTree.Get (lines 99–104).

A re-check of snapshot.IsCurrent after Set cannot un-publish the write — the cache needs a write path that is atomic against the generation (or Advance must invalidate concurrently-backfilling entries).

Comment on lines +64 to +71
public void Commit(ulong blockNumber)
{
baseScope.Commit(blockNumber);

// RootHash now reflects the committed state; the block's final commit stores under its
// final state root. Snapshot so later commits don't mutate the stored set.
buffer.Store(baseScope.RootHash, new HeadStateBlockDelta(_slots.ToFrozenSet(), _requiresFlush));
}

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 — hot-path cost on block processing, and the "block processing is unaffected" claim is inaccurate when the flag is on.

When the cache is enabled this decorator wraps the main processing world state (MainProcessingContext.cs:49), so every storage write goes through CaptureStorageWriteBatch.SetHashSet<StorageCell>.Add, and every Commit builds a fresh _slots.ToFrozenSet().

Two concerns:

  1. Per-block ToFrozenSet allocation/CPU on the processing path for every committed block (including during sync, where the head cache provides no value).
  2. Because _slots is accumulated per scope (not reset per commit), a long sync scope spanning many blocks makes each Commit snapshot the ever-growing set → O(n²) ToFrozenSet work and a HashSet that grows toward MaxTrackedSlots (2^21).

The PR body states "block processing is unaffected" — that's only true with the flag off. Worth measuring the on-flag processing overhead and reflecting it in the description.

Comment on lines +85 to +95
// 1) Journal capture: works on any node, regardless of EIP-7928 / Block Access Lists.
if (block.AccountChanges is { } accountChanges
&& block.Header.StateRoot is { } stateRoot
&& _deltaBuffer is not null
&& _deltaBuffer.TryGet(stateRoot, out HeadStateBlockDelta delta))
{
if (delta.RequiresFlush) return false; // self-destruct: can't enumerate cleared slots
changedAccounts = ToAccountSet(accountChanges);
changedSlots = delta.ChangedSlots;
return true;
}

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 — the journal-captured delta is trusted as authoritative even when it may be incomplete.

If a present delta is found it short-circuits the BAL fallbacks. Coherence at head-X depends on the recorded changed-slot set being complete: any slot whose head value differs from its head-X value must appear in the window, otherwise ChangedInWindow returns false and a stale value gets served/backfilled.

The capture only records slots that flow through IStorageWriteBatch.Set on the decorated processing scope. The PR explicitly lists "coverage for non-standard processing paths (AuRa/Optimism/Taiko)" and parallel BAL execution as not-yet-verified. If any of those write paths (e.g. the Flat/BAL parallel state) bypass the intercepted write batch, the stored delta is silently under-inclusive and is preferred over the (complete) BAL — producing wrong RPC reads rather than just falling back. Please confirm every canonical processing path routes storage writes through this batch, or add an assertion/guard.

Related: this relies on block.AccountChanges including accounts that changed only storage (so their moved storage root is reflected). Worth documenting/asserting that invariant.

Comment on lines +35 to +49
public void Store(Hash256 stateRoot, HeadStateBlockDelta delta)
{
lock (_lock)
{
if (!_byRoot.ContainsKey(stateRoot))
{
_order.Enqueue(stateRoot);
while (_order.Count > capacity)
{
_byRoot.Remove(_order.Dequeue());
}
}
_byRoot[stateRoot] = delta;
}
}

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 — keyed solely by post-state root: distinct blocks sharing a state root collide.

Two blocks with different parents (or a side branch) that reach the same post-state root will share a key here; Store overwrites, so the updater may apply the wrong block's changed-slot set on a sequential advance. The state being identical does not imply the changed-relative-to-parent set is identical, so this can produce an under-inclusive delta → stale head-X reads. Rare, but a silent coherency hole. Consider keying by (parentRoot, stateRoot) or block hash, or at least documenting the assumption that post-state roots are unique within the buffer window.

Comment on lines +24 to +28
builder
.AddSingleton(new HeadStateCache(
blocksConfig.HeadStateCacheDepth,
blocksConfig.HeadStateCacheAccountSetsBits,
blocksConfig.HeadStateCacheStorageSetsBits))

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 — unvalidated config can fail node startup.

HeadStateCacheAccountSetsBits/HeadStateCacheStorageSetsBits outside [1, 20] (and HeadStateCacheDepth < 0) throw ArgumentOutOfRangeException from the SeqlockCache/HeadStateCache constructors, which would abort startup with an opaque error rather than a config-validation message. Since these are user-facing (if hidden) config items, consider clamping or validating with a clear message.

@claude

claude Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 6m 54s —— View job


Review: head state cache for fast RPC reads

Nice, self-contained, opt-in design with good XML docs and a sensible coherence model (head-coherent caches + per-block changed-key window + generation seqlock). My review focused on the concurrency/coherence guarantees and the hot-path impact. Findings below; I left inline comments on the specific lines.

🔴 High

  1. Backfill can publish a stale value for a key the new head changedHeadStateCacheScopeProvider.cs:56-62 (and the equivalent storage path :99-104). The if (snapshot.IsCurrent) check and the following _accounts.Set(...) are not atomic w.r.t. HeadStateCache.Advance. Because Advance deliberately refreshes only already-cached changed keys (uncached ones rely on backfill), a backfill that lands just after an advance can write the old-head value for a key the new block changed. At depth 0 there is no window to filter it, so subsequent head readers serve the stale value until it's changed again or evicted. This undermines the central coherence guarantee. Needs a generation-validated backfill (re-check/stamp + reject), or explicit acknowledgement. (detail inline)

  2. Exception inside Advance permanently breaks the generation seqlockHeadStateCache.cs:132-155. Advance goes odd → does trie reads via IStateReader (which can throw, e.g. MissingTrieNode) → goes even. If a read throws, generation is stuck odd; the recovery Flush assumes an even entry, so it Clear()s while even (a reader can snapshot a half-cleared cache) and exits odd, silently degrading the cache to passthrough until restart. Make Advance exception-safe (try/finally around the second increment) and/or have Flush force generation to a known-even value. (detail inline)

🟡 Medium

  1. "Block processing is unaffected" is inaccurate when the flag is onMainProcessingContext.cs:46-50. HeadStateDeltaCaptureScopeProvider wraps the main processing world state, so every storage write incurs a StorageCell alloc + HashSet.Add, and every block commit a _slots.ToFrozenSet(). Off-by-default makes this acceptable, but the description should be corrected and the overhead benchmarked. (detail inline)

  2. Head refresh runs synchronously on the block-processing threadHeadStateCacheUpdater.cs:47-66. Advance re-reads every changed-and-cached key from the trie on the BlockAddedToMain handler, while generation is odd (RPC readers degraded). changedAccounts from Block.AccountChanges is over-inclusive (all touched accounts, not just mutated — StateProvider.ChangedAddresses()), inflating the work. Consider moving off-thread / bounding, and measure per-block latency with the flag on. (detail inline)

🟢 Low / notes

  • Over-inclusion is otherwise used safely: changedAccounts (touched ⊇ mutated) and accumulate-per-scope slot capture both only cause extra trie deferrals, never staleness. Good.
  • eth_getBalance/eth_getStorageAt bypass the cache (direct IStateReader) — already acknowledged in the PR as a follow-up.
  • HeadStateDeltaBuffer stores on every Commit keyed by post-commit root; if a block ever produces multiple commits, intermediate roots consume buffer slots (capacity 64). Fine for depth 2, but worth keeping capacity comfortably above HeadStateCacheDepth × commits/block.
  • Coherence model itself (window filtering for head-X readers, flush on reorg/gap/self-destruct, null-as-occupied) looks correct for the single-threaded test scenarios; the gaps above are specifically in the concurrent read/advance interleavings, which the current tests don't exercise. A concurrency stress test (already listed as a follow-up) would likely surface Feature/jsonrpc #1.

Verdict: the two High findings concern the core concurrency correctness of the feature, so I'd resolve or explicitly accept them before merge. Since the feature is opt-in and off by default, production blast radius is limited, but the coherence hole (#1) means it can't yet be safely recommended to enable.
· branch feature/head-state-cache

Comment on lines +46 to +50
HeadStateDeltaBuffer? headStateDeltaBuffer = rootLifetimeScope.ResolveOptional<HeadStateDeltaBuffer>();
if (headStateDeltaBuffer is not null)
{
worldState = new HeadStateDeltaCaptureScopeProvider(worldState, headStateDeltaBuffer);
}

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 — when the flag is on, block processing is affected (contrary to the PR description).

The PR states "block processing is unaffected" / "only the shareable tx-processing source is decorated", but HeadStateDeltaCaptureScopeProvider wraps the main processing GlobalWorldState here. With the feature enabled, every storage write on the processing hot path now goes through CaptureStorageWriteBatch.Set → allocates a StorageCell and does a HashSet<StorageCell>.Add, and every block Commit does a _slots.ToFrozenSet() (an allocation proportional to the number of cells the block touched). For a dense block this is non-trivial work/allocation on the critical block-processing path.

This is acceptable as an opt-in/off-by-default experimental cost, but the description should be corrected and the per-block capture overhead measured. Worth a benchmark with the flag on.

Comment on lines +47 to +66
private void OnBlockAddedToMain(object? sender, BlockReplacementEventArgs e)
{
Block block = e.Block;
if (block.Hash is null) return;

lock (_lock)
{
try
{
bool sequential = e.PreviousBlock is null
&& _cache.HeadHash is not null
&& _cache.HeadHash.Equals(block.ParentHash);

if (!sequential || !TryCollectChangedKeys(block, out FrozenSet<AddressAsKey> changedAccounts, out FrozenSet<StorageCell> changedSlots))
{
_cache.Flush(block.Hash);
return;
}

_cache.Advance(block.Hash, changedAccounts, changedSlots, new Refresher(_stateReader, block.Header));

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 (perf) — the head refresh runs synchronously on the BlockAddedToMain handler (main processing thread).

OnBlockAddedToMainAdvance re-reads, via IStateReader (trie traversal), every changed key that is currently cached — and changedAccounts comes from Block.AccountChanges, which is over-inclusive (it contains every account touched, read or written, not just mutated — see StateProvider.ChangedAddresses() returning all _blockChanges.Keys). For a busy block with a warm cache this can be many trie reads, all on the block-processing thread, and all while the generation is odd (so concurrent RPC readers are degraded to passthrough for that window).

Consider doing the refresh off the processing thread, or bounding the number of keys refreshed per advance. At minimum, please measure the added per-block latency with the flag enabled.

(Also note: during fast/snap sync, non-executed blocks won't have a delta and will hit the Flush path on every BlockAddedToMain — wasteful but not incorrect.)

Comment on lines +56 to +62
// Miss (or raced an advance): load at the pinned header root and backfill while still current.
Account? loaded = baseScope.Get(address);
if (snapshot.IsCurrent)
{
_accounts.Set(in key, loaded);
}
return loaded;

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.

High — backfill can publish a stale value for a key the new head changed (coherence hole).

The if (snapshot.IsCurrent) check and the subsequent _accounts.Set(...) are not atomic with respect to HeadStateCache.Advance. Consider:

  1. Reader pinned at head H0 (depth 0, so ChangedInWindow is always false). TryGetValue misses, loaded = baseScope.Get(address) returns the H0 value v0.
  2. Reader evaluates snapshot.IsCurrenttrue (gen still even G).
  3. Reader thread is preempted. Advance(H0→H1) runs to completion: the block changed this account, but it is not currently cached, so Advance skips it (it only refreshes already-cached changed keys, relying on backfill). Gen ends even at G+2, head is now H1.
  4. Reader resumes and executes _accounts.Set(key, v0) — publishing the stale H0 value.
  5. A new depth-0 reader pinned at H1 (snapshot.Generation == G+2) does TryGetValue → hit → returns v0. Wrong. At depth 0 there is no window to filter it out, and nothing re-refreshes it until the key is changed again or evicted, so the stale value can persist for many blocks.

The whole scheme assumes a backfilled value always equals the current head value, but a backfill that lands just after an Advance for a key that block changed violates that. Because Advance deliberately does not refresh uncached changed keys, backfill is the only thing that populates them — and it is racy.

A fix needs the backfill to be validated against the generation atomically (e.g. re-check generation after Set and evict/clear the entry if it moved, or stamp entries with the generation they were written under and reject on read when it differs), rather than the current check-then-act. At minimum this should be explicitly acknowledged given it undermines the core coherence guarantee.

Fix this →

Comment on lines +132 to +155
Interlocked.Increment(ref _generation); // -> odd: readers pass through while we mutate

// Refresh only changed keys that are currently cached; uncached keys get the fresh head value
// lazily on the next read (backfill), so there is no stale entry to fix.
foreach (AddressAsKey address in accountSet)
{
if (_accounts.TryGetValue(in address, out _))
{
_accounts.Set(in address, refresh.GetAccount(address.Value));
}
}
foreach (StorageCell cell in slotSet)
{
StorageCell key = cell;
if (_storage.TryGetValue(in key, out _))
{
_storage.Set(in key, refresh.GetStorage(in key));
}
}

ShiftRings(newHeadHash, accountSet, slotSet);

Interlocked.Increment(ref _generation); // -> even: publish the new head
}

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.

High — an exception inside Advance leaves the generation seqlock permanently odd, and the recovery Flush corrupts parity further.

Advance increments _generation to odd (line 132), then calls refresh.GetAccount / refresh.GetStorage, which do trie reads via IStateReader and can throw (e.g. MissingTrieNode/TrieException under pruning). If that happens, the second increment (line 154) never runs, so _generation stays odd.

The caller's recovery is HeadStateCacheUpdater.OnBlockAddedToMain's catch_cache.Flush(...). But Flush assumes it's entered while even: it does Increment (odd→even here), Clear(), Increment (even→odd here). So:

  • the Clear() runs while the generation is even, meaning a concurrent reader can TrySnapshot mid-flush and observe a half-cleared cache, and
  • Flush exits odd, so TrySnapshot permanently returns false and the cache silently degrades to passthrough until restart.

Recommend making Advance exception-safe (wrap the mutation in try { ... } finally { Interlocked.Increment(ref _generation); } so it always returns to even) and/or making Flush force the generation to a known-even value rather than blind-incrementing.

- Fix data race / use-after-free: stop reading the pooled Block.AccountChanges
  (TxPool owns and disposes it concurrently). Capture changed accounts in the
  journal delta via the write batch instead — precise (mutated, not merely
  touched) and independently owned.
- Fix backfill-vs-Advance race: serialize cache writes (backfill, Advance,
  Flush) under a write lock; backfill is generation-checked and non-blocking
  (TryEnter), so a value loaded at the old head is dropped, never published
  stale. Reads stay lock-free.
- Make Advance exception-safe: gather refreshed values before touching the
  generation, and only flip generation in a no-throw phase under try/finally,
  so a trie read failure can't leave the seqlock stuck odd. Flush is parity-safe.
- Capture changed storage cells thread-safely (parallel storage-root workers):
  per-batch buffering + locked merge.
- Key the delta buffer by (blockNumber, stateRoot) to reduce collisions.
- Clamp head-cache config to valid ranges so misconfig can't abort startup.
- Tests: parallel-capture thread-safety; updater journal-delta advance/flush.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@LukaszRozmej

Copy link
Copy Markdown
Member Author

Thanks for the thorough review — addressed in 9470547f85.

🔴 Critical — pooled block.AccountChanges race: fixed. The updater no longer touches Block.AccountChanges. Changed accounts are now captured in the journal delta from IWorldStateWriteBatch.Set on the decorated processing scope (independently owned, and precise — only mutated accounts, not all touched). No shared ownership with TxPool.

🟠 High — backfill vs Advance race: fixed. All cache writes (backfill, Advance, Flush) are now serialized under a write lock; reads stay lock-free. Backfill is generation-checked under the lock, so a value loaded at the old head is dropped (never published) if an advance happened meanwhile. Backfill uses TryEnter (non-blocking) so it never stalls an RPC read behind an in-progress advance.

🟠 High — exception in Advance stuck-odd generation: fixed. Advance now gathers refreshed values (the throwing trie reads) before touching the generation, and only flips generation in a no-throw in-memory phase wrapped in try/finally — so a MissingTrieNode etc. leaves the cache untouched and even. Flush is parity-safe under the lock.

🟡 Medium — buffer keyed only by state root: now keyed by (blockNumber, stateRoot), with the residual collision documented.

🟡 Medium — journal trusted even if incomplete: the capture decorator is only installed on the main processing context, whose authoritative final commit routes every storage write through the intercepted IStorageWriteBatch.Set (confirmed via the CommitStateAndStorageRoots → FlushToTree → ProcessStorageChanges path). Non-standard paths (AuRa/OP/Taiko) are not decorated → no delta produced → BAL/flush fallback, so they can't yield an under-inclusive delta.

🔵 Low — unvalidated config: clamped to valid ranges in the module.

🟡 Medium — "block processing is unaffected" is inaccurate with the flag on: correct — when enabled, the capture decorator adds a HashSet.Add per storage write and a ToFrozenSet per commit on the main processing path. The PR description's claim only holds with the flag off (the default). I'll correct the wording.

Also fixed the original cause of your run-time crash (the HashSet corruption): parallel storage-root workers now merge through per-batch buffers under a lock.

Deliberately deferred (follow-ups, noted in the PR): moving the synchronous head refresh off the block-processing thread; benchmarking the on-flag capture overhead (incl. the O(n²) accumulation across a long sync scope, currently bounded by the cap→flush); extending the cache to the direct IStateReader RPCs (eth_getBalance/eth_getStorageAt). Happy to pull any of these into this PR if you'd prefer.

Follow-ups from review:
- Move the head-cache refresh off the BlockAddedToMain thread onto a single
  background worker (bounded queue; dropped events self-heal via the
  parent-mismatch flush), so the trie-bound refresh no longer delays block
  processing or other event subscribers.
- Capture the per-block changed set per block (per-commit buffering folded into
  a per-block set, reset on block-number change) instead of accumulating across
  a whole processing scope — removes the O(n²) snapshot cost on long sync
  branches and makes the delta per-block-accurate.
- Extend the cache to the direct IStateReader RPCs (eth_getBalance,
  eth_getStorageAt, …) via HeadCachedStateReader decorating the shared reader.
  The updater is wired with the raw GlobalStateReader to avoid a
  refresh-reads-the-cache cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@LukaszRozmej

Copy link
Copy Markdown
Member Author

Didn't provide desired improvements

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant