Skip to content

fix(flat): isolate warmer misses and safely publish verified reads - #12951

Merged
kamilchodola merged 18 commits into
masterfrom
perf/flat-warmer-publish-resolved
Aug 25, 2026
Merged

fix(flat): isolate warmer misses and safely publish verified reads#12951
kamilchodola merged 18 commits into
masterfrom
perf/flat-warmer-publish-resolved

Conversation

@kamilchodola

@kamilchodola kamilchodola commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Changes

Supersedes #12924. Flat-layout trie warmer: keep reusing what the warmer resolves, without ever sharing a mutable node with block processing.

  • A warmer miss is a warmer-owned Unknown placeholder in the transient Nodes (SnapshotBundle.CreateWarmerUnknownNode). It resolves through a resolving/resolved flag protocol on TrieNode (_blockAndFlags): one resolver loads the RLP, verifies Keccak(rlp) == requested hash, decodes, then publishes the resolved bit; other resolvers wait. A failed verification leaves it unresolved.
  • Live reads reuse the warmer's instance only once it is resolved (SnapshotBundle.FindStateNodeOrUnknown / FindStorageNodeOrUnknown gate); otherwise they fall through to the snapshots/persistence as before.
  • TrieNodeCache.Add waits for in-flight warmer reads to drain (TransientResource.WaitForExclusiveLease) and promotes a detached, re-verified copy of each resolved warmer node (sharing the RLP array, not the object).
  • PatriciaTree.DoWarmUpPath treats an unresolvable node as the end of the warm-up. Flat persistence is path-keyed, so a warmer read can legitimately return another version of the node at that path; that is staleness, not an error, and it must not reach the log as one.

Why

Validation

  • Sync Master Validation (Flat, mainnet + gnosis) on this tree: 32775387528 ✅.
  • EXPB same-session A/B vs master, amd64 flat, run_count=3 per arm, both orderings:
  • SnapshotBundleWarmerTests 20/20, Nethermind.Trie.Test 475 passed.

Types of changes

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature
  • Breaking change
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

Regression tests cover: stale (other-version) RLP is not resolved, not served to live reads and not promoted; invalid RLP likewise; a live read during an in-flight resolution stays Unknown; concurrent resolvers load once; promotion yields a detached copy; WarmUpPath does not throw on a stale path-keyed read.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

AnkushinDaniil and others added 3 commits August 20, 2026 16:05
…cture

#12877 restored the warmer's negative cache by writing the miss placeholder
(NodeType.Unknown, empty RLP) into the shared _transientResource.Nodes and
tried to keep it safe with an IsPlaceholder predicate at every live-read site
and at the shared-cache promotion. That coexistence is the defect: the same
placeholder instance sits in the cache that live reads consult, so a warmer
resolve racing a live read reopens the InvalidStateRoot window #12793 closed
(reproduced on Flat sync at live head, mainnet and gnosis; HalfPath unaffected).

Remove the coexistence instead of guarding it. Warmer misses now go to a
dedicated TransientResource.MissNodes cache that no live read ever consults and
that TrieNodeCache.Add never promotes. Nodes therefore holds only real nodes,
so the IsPlaceholder guards and the predicate are dead code and are dropped.
The negative cache is preserved (a repeated warmer miss is still served without
re-probing persistence), so the #12793 perf recovery stands.
…iant

Reword the XML doc to claim only what is verified — no live read consults
MissNodes and TrieNodeCache.Add never promotes it — instead of the stronger
'a stale miss can never reach the state-root computation', which does not
hold: the placeholder also escapes as the warmer return value and is later
resolved by a live read through persistence (by hash, correct by construction,
matching #12793 and pre-dating this PR).
…sient

Isolating the warmer's negative cache in `MissNodes` is what makes #12924
correct, but it also strands the warmer's work. A warmer miss means the node
is absent from the in-memory structures, not from persistence: the warmer
returns a placeholder and the traversal resolves it through
`ReadOnlySnapshotBundle.TryLoadStateRlp`, i.e. a real persistence read. That
placeholder lives in `MissNodes`, which neither live reads nor
`TrieNodeCache.Add` consult, so every node the warmer pulls off disk is
fetched, decoded and then discarded at block end while the live path repeats
the same read.

Publish what the warmer read: a private `TrieNode` built from the RLP,
decoded before it is stored in `TransientResource.Nodes`, never handed to the
warmer. The block's live reads and the cross-block `TrieNodeCache` reuse the
read; no reader can observe a node mid-resolution, which is the hazard that
made publishing the warmer's own instance unsafe (`IsPlaceholder` was a
predicate over `NodeType`/`FullRlp` while another thread wrote both).

Persistence read count is unchanged: the publish is a side effect of the read
the warmer already performs, not an extra one.
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 10m 42s —— View job


Review: publish the trie warmer's persistence reads into the transient

  • Read PR diff vs origin/daniil/fix-warmer-stateroot
  • Traced SnapshotBundle warmer find/load paths, TransientResource, TrieNodeCache.ChildCache, ReadOnlySnapshotBundle, FlatWorldStateScope/FlatStorageTree, BranchProcessor
  • Correctness & thread-safety analysis
  • Performance & repo-rule check
  • Posted 5 inline comments

The diagnosis in the description is right and well-argued: TryFindStateNodeInPersistence only searches decoded in-memory snapshots, so a warmer "miss" really does cost a RocksDB read whose result #12924 throws away. Publishing a private, pre-decoded node is also genuinely the right shape of fix for the instance-sharing hazard that killed #12877Nodes keeps holding only resolved nodes, and nothing needs a lease-drain barrier. My concern is with a second invariant the description doesn't cover.


Critical

The published node's Keccak is asserted from the caller's hash, but the RLP came from a path-keyed read that ignores hash.
SnapshotBundle.cs:442-447 · inline

ReadOnlySnapshotBundle.TryLoadStateRlp/TryLoadStorageRlp never look at hash — they read persistedSnapshots then persistenceReader by path alone. new TrieNode(NodeType.Unknown, hash, rlp) then stamps hash onto whatever came back. Every reader-side guard in this area (ChildCache.TryGet, TrieNodeCache.TryGet, StateTrieStoreAdapter.FindCachedOrUnknown's NodeHashMismatchException) compares against the claimed Keccak, so all of them are satisfied by construction and none can detect a mismatch.

The needed invariant — a warmer persistence read at path always returns the node with hash — holds for state (_warmupStateTree.RootHash is set once at scope entry and Commit never advances it), but not for storage. On a multi-block branch (BranchProcessor.cs:71 opens one scope, CommitTree runs per block up to MaxUncommittedBlocks):

  1. Commit(i) pushes onto _snapshots, swaps the transient, sets _trieChanged = false, and clears _storages.
  2. Block i+1 re-creates FlatStorageTree with Get(address).StorageRoot, which resolves through _snapshots[i] — a post-block-i storage root — and roots _warmupStorageTree there.
  3. The warmer skips _snapshots by design, and the fresh transient is empty, so any of block i's nodes that the direct-mapped TrieNodeCache didn't retain miss → TryLoadStorageRlpForWarmer → pinned persistence returns the pre-scope node → published under the post-block-i hash.
  4. Block i+1's live FindStorageNodeOrUnknown consults _transientResource before DoTryFindStorageNodeExternal (which would have found the correct node in _snapshots), and _trieChanged is false so _changedStorageNodes is skipped. The poisoned entry matches on hash and wins.

That yields a stale subtree → wrong storage root → InvalidStateRoot, and TrieNodeCache.Add then promotes the poison cross-block. Same symptom as run 32438552571, different mechanism from the one the description rules out. Cheapest fix is ValueKeccak.Compute(rlp) == hash before publishing — one Keccak per warmer disk read, which then belongs in the cost accounting. Alternatives (state-only publishing; rooting _warmupStorageTree in the warmer-visible view) are in the inline comment.

I want to be clear about my confidence: steps 1–4 are each read off the code, but I have not run this, and the queued Sync Master Validation mode=Both on flat mainnet/gnosis is the thing that would actually confirm or refute it — the same gate that caught #12793/#12877/#12924.

Medium

  • ChildCache.GetOrAdd overwrites on hash mismatch, so publishing can evict the authoritative node. SnapshotBundle.cs:452-461 — benign before this PR (the warmer only re-added nodes it found in-memory); now an in-flight publish can overwrite a node SetStorageNode just committed, and that entry is what gets promoted to the shared cache. Perf/pollution rather than corruption, but it shouldn't be assumed to be inside the noise.
  • A publish-side decode failure now fails the warmer's read. SnapshotBundle.cs:447ResolveNode throws TrieNodeException/TrieException, and TrieNode.TryResolveNode only has catch (RlpException), so a case that previously degraded to return false now throws out of TryLoadRlp. Publishing is an optimisation and should not be able to change the read's outcome.

Low

  • The TrieNode ctor overload probably copies the RLP. SnapshotBundle.cs:446 — C# 14 span betterness should pick ReadOnlySpan<byte> over CappedArray<byte> for a byte[] argument, so rlp.ToArray() runs per published node. Sharing the array is safe here (the node is clean/persisted, so ResolveKey's SafeReturn never touches it); suggestion inline.
  • The new test's first assertion is vacuous and the test bypasses the changed call path. SnapshotBundleWarmerTests.cs:166-182warmed is never resolved, so NodeType == Unknown holds trivially despite the comment saying otherwise; and there is no case covering a reader that returns RLP for a different node than hash, which is the failure mode that matters.
  • A warmer miss now occupies a slot in both MissNodes and Nodes, and Nodes._count (which drives ChildCache.Reset's regrow decision) climbs faster. Worth a sentence in the <remarks>.

Things I checked and found fine

  • IsPersisted is set correctly (NodeType.Unknown + keccak ctor sets it), matching other persistence-sourced nodes, so PrunePersistedRecursively(1) behaves.
  • TryLeaseTransientResource reuse is correct: the publish takes its own lease/ABA round-trip and bails on a torn-down bundle, and the finally release is unconditional.
  • GuardDispose behaviour is unchanged — the warmer adapters already routed through TryLoadState/StorageRlp.
  • The FlatTestHelpers.MakeBundle overload and its <inheritdoc> are clean and don't disturb existing callers.
  • The change is additive: no new public surface, no existing call sites rewritten beyond the two adapter lines. That matches the AGENTS.md preference ordering.

Not done

I could not run dotnet build or the test suite in this environment (the commands weren't permitted), so the overload-resolution point above is analytical rather than verified, and I'm relying on your local SnapshotBundleWarmerTests 7/7 result.

Agreed with the draft framing — the two gates you've queued (Sync Master Validation mode=Both on flat mainnet + gnosis, and the same-session expb A/B) are exactly the right ones, and the Critical item above is precisely the kind of thing only the first of those will settle.
· branch perf/flat-warmer-publish-resolved

Comment thread src/Nethermind/Nethermind.State.Flat/SnapshotBundle.cs Outdated
Comment thread src/Nethermind/Nethermind.State.Flat/SnapshotBundle.cs Outdated
Comment thread src/Nethermind/Nethermind.State.Flat/SnapshotBundle.cs Outdated
Comment thread src/Nethermind/Nethermind.State.Flat/SnapshotBundle.cs Outdated
Comment thread src/Nethermind/Nethermind.State.Flat.Test/SnapshotBundleWarmerTests.cs Outdated
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

EXPB Benchmark Comparison

Run: View workflow run

superblocks

Scenario: nethermind-flat-superblocks-perf-flat-warmer-publish-resolved-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 828.54 823.68 +0.59%
MEDIAN (ms) 802.2 787.7 +1.84%
P90 (ms) 980.5 978.0 +0.26%
P95 (ms) 1021.3 1039.9 -1.79%
P99 (ms) 2748.2 2131.7 +28.92%
MIN (ms) 577.4 555.2 +4.00%
MAX (ms) 2748.2 2131.7 +28.92%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 1380.66 1467.88 -5.94%
MEDIAN (ms) 976.61 992.14 -1.57%
P90 (ms) 2469.53 2685.88 -8.06%
P95 (ms) 2970.22 3437.67 -13.60%
P99 (ms) 4613.66 4014.66 +14.92%
MIN (ms) 663.64 643.07 +3.20%
MAX (ms) 7437.37 8764.46 -15.14%

realblocks

Scenario: nethermind-flat-realblocks-perf-flat-warmer-publish-resolved-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 20.15 19.79 +1.82%
MEDIAN (ms) 17.4 17.2 +1.16%
P90 (ms) 34.3 33.3 +3.00%
P95 (ms) 40.5 39.6 +2.27%
P99 (ms) 65.1 61.0 +6.72%
MIN (ms) 0.2 0.3 -33.33%
MAX (ms) 190.3 186.9 +1.82%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 23.81 24.01 -0.83%
MEDIAN (ms) 20.89 20.45 +2.15%
P90 (ms) 37.90 37.80 +0.26%
P95 (ms) 44.23 44.15 +0.18%
P99 (ms) 69.28 80.75 -14.20%
MIN (ms) 0.73 1.08 -32.41%
MAX (ms) 349.86 399.80 -12.49%

fusaka

Scenario: nethermind-flat-fusaka-perf-flat-warmer-publish-resolved-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 28.10 28.43 -1.16%
MEDIAN (ms) 25.7 26.1 -1.53%
P90 (ms) 44.4 45.0 -1.33%
P95 (ms) 54.6 53.5 +2.06%
P99 (ms) 72.7 74.1 -1.89%
MIN (ms) 4.3 3.5 +22.86%
MAX (ms) 318.6 322.1 -1.09%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 35.52 36.30 -2.15%
MEDIAN (ms) 31.25 31.44 -0.60%
P90 (ms) 53.27 54.51 -2.27%
P95 (ms) 62.47 60.98 +2.44%
P99 (ms) 89.59 93.93 -4.62%
MIN (ms) 5.75 4.48 +28.35%
MAX (ms) 695.82 672.70 +3.44%

…d node

Review catch on the previous commit, and a real defect in it.
`ReadOnlySnapshotBundle.TryLoadStateRlp`/`TryLoadStorageRlp` take a `hash` but
never use it: the read is keyed by path alone. Publishing those bytes as
`new TrieNode(NodeType.Unknown, hash, rlp)` therefore *stamps* the requested
hash onto whatever node currently sits at that path, and the warmer can ask
for a (path, hash) pair the pinned persistence view does not hold - it reaches
newer pairs through the cross-block `TrieNodeCache`, and on a multi-block
branch through a post-commit storage root. Every reader-side guard
(`ChildCache.TryGet`, `TrieNodeCache.TryGet`,
`FindCachedOrUnknown`'s `NodeHashMismatchException`) compares against the
node's claimed `Keccak`, so all of them would be satisfied by construction and
a stale node would be served to live reads and promoted cross-block:
`InvalidStateRoot`, the symptom this whole chain keeps producing.

Verify the binding instead of asserting it - publish only when the RLP hashes
to the requested hash - and state the invariant in the `<remarks>`.

Also from the review:
- `TryResolveNode` instead of `ResolveNode`, so a publish-side decode failure
  cannot change the outcome or exception type of the read it piggybacks on.
- Pick the `ReadOnlySpan<byte>` ctor explicitly so overload resolution cannot
  silently flip between copying and aliasing the caller's array.
- Document the costs honestly: a Keccak per published node, the RLP copy, the
  dual `MissNodes`/`Nodes` slot and its effect on the regrow decision, and the
  `GetOrAdd`-replaces-on-mismatch interaction with a racing trie commit.
- Tests now drive the real warmer adapter path rather than calling the bundle
  method directly, dropping an assertion that held trivially, and cover the
  mismatch case: RLP for node A returned at the path where the warmer asks for
  node B must publish nothing. Verified non-vacuous - removing the hash guard
  fails both cases.
@kamilchodola kamilchodola changed the title perf(flat): publish the trie warmer's persistence reads into the transient fix(flat): isolate warmer misses and safely publish verified reads Aug 24, 2026
@kamilchodola
kamilchodola changed the base branch from daniil/fix-warmer-stateroot to master August 24, 2026 07:21
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 9m 30s —— View job


Re-review: fix(flat): isolate warmer misses and safely publish verified reads

  • Read the current diff vs the PR base (942eabf)
  • Verify the four fixes claimed in 7ebd4ba landed as described
  • Re-trace correctness (hash verification, MissNodes isolation, publish races, IsPlaceholder removal)
  • Check the new tests are non-vacuous and go through the real adapter
  • Build + run SnapshotBundleWarmerTestsnot possible here (dotnet build/dotnet test are outside this job's allowed tools; see Not done)
  • Posted 2 inline comments

All five items from the previous round are addressed, and the critical one is addressed in the strongest available way. I re-derived the fix rather than taking the reply at face value, and I also went looking for what the isolation half of the change might have broken, since that half is new since my last read.


Previous round — verification

# Item Status
Critical hash asserted, not verified Fixed. SnapshotBundle.cs:468if (ValueKeccak.Compute(rlp) != hash) return; before anything is published. ValueHash256 != Hash256? resolves to the value comparison at Hash256.cs:103, not reference equality. Regression test present and I confirmed it is non-vacuous (below).
Medium decode failure changes the read's outcome Fixed, and better than asked. TryResolveNode + bail on false (:473). The residual worry — DecodeRlp throwing something outside the catch (RlpException) — is now unreachable rather than merely handled: the hash check runs first, so the bytes are the canonical encoding of a real node.
Medium GetOrAdd can evict the authoritative node Acknowledged with rationale in <remarks>, with the correct reasoning (evicted node stays authoritative in _changedStateNodes/_snapshots, so it costs a re-read). I agree ChildCache has no add-if-absent and adding one isn't worth it unverified.
Low RLP copy / silent overload flip Explicit cast added. Keeping the copy is the right call — your point about the same byte[] also going back to the warmer via WriteRlp is stronger than my SafeReturn argument, because it makes the aliasing invariant span two independently-lived nodes.
Low vacuous assertion / bypassed call path Fixed. Both tests now drive warmed.TryResolveNode(WarmerResolver(...)), so StateTrieStoreAdapter.cs:55/110 are actually exercised.

Your extra point about TryFindStateNodeInPersistence consulting the cross-block TrieNodeCache first is right, and it does kill state-only publishing: the cache holds nodes promoted from previous blocks' transients, so the warmer can resolve a newer parent from the cache and then ask pinned persistence for a child at (path, newHash) — a single-block, state-trie instance of the same mismatch. Option 1 was the correct choice.

New checks this round

The isolation commits (6da4345, b772b09) removed TrieNodeCache.IsPlaceholder and its two call sites. Since that deletes a guard, I traced whether anything other than the warmer can still put a placeholder into Nodes:

  • Every writer to Nodes is now UpdateState/StorageNode (commit), GetOrAddState/StorageNode at :220/:351 (warmer persistence hits), and :483/:487 (publish). Placeholders go only to MissNodes.
  • The deleted doc claimed "the trie warmer's negative cache and the trie commit path both produce it" — the commit-path half was wrong. PatriciaTree.cs:271-278 calls ResolveKeySeal() → commits only when FullRlp.Length >= 32, so a committed node always has a real NodeType and ≥32 bytes of RLP. The guard was warmer-only, and removing it is safe.
  • Isolation is a strict improvement on the base, beyond correctness: on master a warmer miss did Nodes.GetOrAdd(placeholder), which evicts a real node from that direct-mapped slot for live readers. That no longer happens.
  • Published nodes never reach the DB. Nodes is not a snapshot source (CollectAndApplySnapshot builds from _currentPooledContent; TransientResource's own summary states it is "not committed into IFlatDbManager as part of a Snapshot"). So the worst case for a publishing bug is bounded at in-memory cache poisoning — which is what the hash check now closes.
  • TrieNodeCache.Add(retired) runs while FlatDbManager.PopulateTrieNodeCache still holds the owner lease, and Commit sets _pausePrewarmer = true without draining outstanding warmups, so an in-flight publish can write into the retired transient concurrently with Add's shard scan. ChildCache's 16-byte tuple is written non-atomically, so a torn read can file a node under the wrong bucket — but TrieNodeCache.TryGet verifies Keccak == hash, so the outcome is a cache miss, not corruption. This race is unchanged from master (the warmer already wrote to Nodes at :220/:351); not a finding against this PR, noting it so it isn't mistaken for one later.
  • Test non-vacuity, checked by hand rather than assumed: in Warmer_does_not_publish_a_node_the_rlp_does_not_hash_to, dropping the guard makes Nodes hold a node with Keccak == requestedHash, so the live read returns NodeType.Leaf and both assertions fail. EncodedLeaf() produces 37 bytes (0xc0+36: 3-byte hex-prefix key + 33-byte value), so it is genuinely hash-referenced rather than inlined, as its summary claims. Warmer_miss_does_not_reach_live_reads_or_trie_node_cache would also pass on master — it pins the new structure (fails if MissNodes is dropped without restoring IsPlaceholder), which is the right guard to have.
  • Also fine: IsPersisted on the published node (TrieNode.cs:356-359), so PrunePersistedRecursively(1) behaves; the publish's own lease/ABA round-trip and unconditional finally release; TreePath nodePath = path so the keying path isn't the one TryResolveNode mutates; the FlatTestHelpers.MakeBundle overload and <inheritdoc>.

Findings

Medium — MissNodes doubles the transient node-array footprint, and GetSize() can't see it. TransientResource.cs:42 · Fix this →
ChildCache allocates all 256 shards in its constructor, so a Nodes capacity of ~400k entries (≈6.4 MB) is now matched by an equally-sized MissNodes — paid per TransientResource, in every Usage pool, including read-only/RPC scopes whose MissNodes stays empty. Separately, GetSize() (line 76) still reports only Nodes.Capacity, so if MissNodes.Reset() regrows past Nodes, the next pool-created resource silently constructs it smaller than the workload demonstrated it needs — degrading exactly the negative-cache hit rate this PR exists to protect. The <remarks> price the slot accounting but not this. Suggested one-line fix inline.

Low — the cost accounting undersells the node duplication. SnapshotBundle.cs:425
"one TrieNode" reads as if the published copy is the only node-level cost, but the MissNodes entry stops being a placeholder the moment the warmer resolves it — TryResolveNode does WriteRlp + DecodeRlp in place (TrieNode.cs:467-476). So a missed path retains two fully-materialised copies until Reset, where master retained one. Deliberate price of not sharing the instance, and I'd keep the design; just worth naming, and worth reading allocation/GC off the pending A/B (the EventPipe sidecar already has it) rather than wall-clock alone.

On the validation gate

Flagging without re-litigating, since the PR body says it plainly: the only EXPB comment on this PR (32506853474) compared against a cached master baseline and produced no realblocks metrics — the payload set that matters most for a storage-layer change — and the +2.2% AVG / −5.5% P99 figure quoted in the body is from the isolation-only ancestor, not this tree. The ValueKeccak.Compute now sits on the path the change exists to make cheaper. My read is that it should still win (sub-µs Keccak against the RocksDB read it prevents), but that's a prediction, not a measurement, and your own fallback (memoise path → rlp at the RLP layer, no hash claim needed, no cross-block promotion) only becomes comparable once there's a same-session A/B. Likewise the correctness gate: the sync evidence in the body predates the hash check, so it validated a tree that had the defect. Neither is a code objection — both are the gates I'd want green before merge, and both are yours to call.

Not done

dotnet build and dotnet test were both refused in this environment, so everything above is static analysis; I did not reproduce your 14/14 SnapshotBundleWarmerTests result. I did verify by hand the things a compiler would have caught: the ValueHash256/Hash256 operator exists, TrieNodeFactory is public with a (ReadOnlySpan<byte>, CappedArray<byte>) overload that accepts [0x3, 0x4] / new byte[32], Nethermind.State.Flat.Test has InternalsVisibleTo for StateTrieStoreWarmerAdapter, and the new using Nethermind.Trie.Pruning; in SnapshotBundle.cs doesn't collide with the fully-qualified Nethermind.Trie.Pruning.Metrics references already in that file.

Verdict: the correctness objection from last round is properly resolved, and I found nothing new at Critical or High. The one Medium is a resource-cost question, not a correctness one.
· branch perf/flat-warmer-publish-resolved

Comment thread src/Nethermind/Nethermind.State.Flat/TransientResource.cs Outdated
Comment thread src/Nethermind/Nethermind.State.Flat/SnapshotBundle.cs Outdated
@kamilchodola
kamilchodola marked this pull request as ready for review August 24, 2026 09:09

@benaadams benaadams left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Three remaining findings from the local review.


start.Set();
bool firstLoadStarted = loadStarted.Wait(BailOutTimeout);
allowLoad.Set();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Medium — the concurrency test never exercises a live read while resolution is in flight. It releases the blocked load immediately, so a regression that publishes the resolved bit before RLP/decode completion could still pass this test while exposing the mutable shared node to block processing. Before allowLoad.Set(), perform the matching live state/storage lookup and assert that it is not warmed and remains Unknown.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 58bd117: the test now does the matching live state/storage lookup while the load is parked and asserts it is not warmed and stays Unknown.


[TestCase(false)]
[TestCase(true)]
public void Unresolved_warmer_miss_does_not_reach_trie_node_cache(bool storage)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Low — the storage: false case duplicates the preceding Unresolved_warmer_miss_is_not_promoted scenario. It covers the same state-cache behavior and adds stronger live-read assertions. Per the test-duplication rule, remove the preceding test or fold its explicit cache assertion into this parameterized case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed Unresolved_warmer_miss_is_not_promoted in 58bd117; the parameterized case covers it.

using (Assert.EnterMultipleScope())
{
Assert.That(live.NodeType, Is.EqualTo(NodeType.Leaf));
Assert.That(live, Is.SameAs(warmed));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Low — the PR description no longer matches the final implementation. It still describes MissNodes and a private published node that is never shared with the warmer, but this assertion confirms that live readers intentionally reuse the warmer instance and only retirement detaches it. Please update the description to the final resolving/resolved protocol; the targeted run is also 21 tests, not 14.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

PR description rewritten to the resolving/resolved protocol that is actually in the tree, with the validation section pointing at the runs on this tree.

@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude Please check if the changes made in this PR are actually solving the performance degradation problem OR there was some different bottleneck fixed which obfuscate perf loses by initial changes. Check the history of related PRs.

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 12m 31s —— View job


Does this PR actually solve the degradation, or is something else masking it?

Short answer: no, it does not. On the only clean measurement available, this tree is ~1.6% slower than master on flat/realblocks, and the reported delta improving from +4.52% to +1.61% over today's commits is mostly the master baseline drifting upward, not the branch getting faster. And yes — the historical "negative cache" framing mis-attributes the bottleneck: the money was never in the miss short-circuit, it was in reusing/promoting the warmer's resolved node, which is the same mechanism that has produced InvalidStateRoot twice.


1. What the measurements actually say

The one trustworthy datapoint is your own dispatched A/B — run 32733971643: flat / realblocks / amd64 / delay=0, run_count=3, two images, master@aa490c4 vs perf-flat-warmer-publish-resolved@017f376. I confirmed git diff 017f376 HEAD -- Nethermind.State.Flat Nethermind.Trie is empty, so this measures HEAD's code, and aa490c4 is on this branch's own history — the single difference is this diff.

arm run 1 run 2 run 3 mean AVG
master@aa490c4 19.33 19.71 19.69 19.58 ms
this branch 20.06 19.77 19.85 19.89 ms

AVG +1.61%, and the ranges do not overlap — the branch's best run (19.77) is worse than master's worst (19.71). Median +0.6%, P90 +2.7%, P95 +1.7%, P99 ≈ flat (−0.3%). So it is a small, consistent, body-of-the-distribution regression, not a tail artifact.

Now the per-commit pull_request runs from today (1 run each, cached master baseline — weaker, but they cover the design's evolution):

tree branch AVG master baseline reported Δ
b31cdf8 MissNodes + detached publish + hash verify 20.37 19.49 (cached) +4.52%
0e2353c "optimize safe promotion" 20.09 19.52 (cached) +2.92%
efb875f "avoid copying published RLP" 20.05 19.52 (cached) +2.72%
017f376 "synchronize shared resolution" 20.03 — (no baseline) n/a
5c792cc (HEAD, the table in the PR comment) 20.21 19.89 (cached) +1.61%

Read the branch column, not the delta column: 20.37 → 20.09 → 20.05 → 20.03 → 20.21. The branch has been flat within rig noise all day. The headline moving from +4.52% to +1.61% is almost entirely the cached master aggregate rising 19.49 → 19.89. Every measurement of this branch, on both methodologies, puts it above master. Best estimate of the real cost: +0.3–0.5 ms/block, ~1.6–2.5% on flat/realblocks.

(Superblocks −1.84% and fusaka −0.32% in the PR comment are single runs against a cached baseline, and superblocks P99 swings ±34% run to run on this rig — I would not read a sign off either.)

2. The history: the bottleneck was mis-labelled

PR what it did to the warmer's miss path measured
#12429 warmer stops reading recyclable _snapshots (recycle race)
#12793 (merged) on a miss, warmer gets an unshared Unknown. Loses three things at once: (a) the negative cache, (b) live reads reusing the warmer's resolved node, (c) cross-block promotion of it into TrieNodeCache +2.6…5.2% (n=3)
#12877 (merged, = today's master) restores (a)+(b)+(c) in one move by putting the shared instance back in Nodes behind an IsPlaceholder predicate −2.7% — but on superblocks, while the regression was demonstrated on a ~30 ms/block set. Recovery was never shown on the payload set where the loss was shown. Then InvalidStateRoot on flat live-head sync (run 32157771920)
#12924 (closed) keeps only (a)MissNodes, fully working negative cache, isolated +2.2% realblocks vs master
#12924's dropped experiment (a) + (c) promotion of resolved misses −0.42% — parity — and InvalidStateRoot on flat mainnet + gnosis (32438552571)

That pair of rows is the decisive experiment and it is already in your own history: a fully working, isolated negative cache is worth ≈ 0. Nearly all of #12877's gain came from (b)+(c) — the warmer's resolved node being reused by live reads and promoted cross-block. Which is exactly the shared-instance retention that fails the sync gate. Perf and the defect are two faces of one mechanism, so "restore the negative cache" was never going to buy the regression back, and #12924 measuring +2.2% with a perfect negative cache confirms it.

This PR (in its current, rewritten form) does restore (b) and (c) — that is what IsWarmerOwned/IsWarmerResolved gating plus WaitForExclusiveLease + detached re-materialisation are for — and it still measures +1.6%. So the safety devices cost approximately what the mechanism buys. It lands between #12924's +2.2% and the unsafe −0.42%, recovering maybe a quarter of the gap.

3. Where the residual cost is, in the code

The design pays new CPU on precisely the path it exists to accelerate:

  • Two Keccaks per warmer node. VerifyWarmerOwnedRlp hashes the RLP on every warmer-owned resolution (TrieNode.cs:687, called at :496/:616), and TryMaterializeResolvedWarmerNode hashes the same bytes again at promotion (TrieNodeCache.cs:135-160).
  • A second full RLP decode per promoted node, plus a fresh TrieNode, inside the per-block Parallel.For (TrieNodeCache.cs:164-176). Master promotes the existing instance for free. This runs on the PopulateTrieNodeCache channel consumer (FlatDbManager.cs:174-190), so it is CPU that block processing competes for and it delays the transient's return to the pool.
  • A drain barrier on retirement: Add now opens with transientResource.WaitForExclusiveLease() (TrieNodeCache.cs:103, TransientResource.cs:53) — an unbounded spin.
  • Prewarmer threads now serialise on hot upper nodes. TryAcquireWarmerResolution (TrieNode.cs:645) is a per-node spin lock: the first thread does the RocksDB read + Keccak + decode while every other visitor spins for its duration. On master those threads each did their own (racy, but parallel) read. High-fanout upper nodes are visited many times per block — that is the whole premise of the negative cache — so this is the most likely home of the residual regression, and it is the one thing none of the three "optimize" commits touched.
  • Costs paid by layouts that get no benefit: ResolveNode/TryResolveNode gained an IsWarmerOwned volatile read + branch at the top of the universal resolution path (TrieNode.cs:404, :547), inline-child creation now routes through CreateInlineChild (:1475), and live transient hits do two extra volatile reads (SnapshotBundle.cs:170, :298). HalfPath and every other Nethermind.Trie consumer pays these. Per AGENTS.md's preference ordering this is also the least-preferred shape — 220 new lines of flat-warmer state machine inside core TrieNode, plus a new InternalsVisibleTo("Nethermind.State.Flat").

4. So: was a different bottleneck fixed, masking the loss?

Two distinct answers, both yes:

  1. Attribution. The thing fix(flat): restore the trie warmer negative cache without poisoning live reads #12877 fixed was not the negative cache — it was warmer-read reuse + cross-block promotion, and fix(flat): isolate the trie warmer negative cache (InvalidStateRoot on Flat live-head sync) #12924's own two runs prove it. Every PR in this chain that quotes a "negative cache" percentage is quoting a number produced by a different mechanism.
  2. Baseline drift. The reported delta shrinking to +1.61% is the baseline moving, not the branch improving (§1). Separately, absolute block time on this rig has moved a lot while this work has been in flight (the fix(flat): stop the trie warmer poisoning reads with a cached Unknown #12793-era figures were ~30 ms/block; realblocks master is now ~19.5 ms) with unrelated hot-path work landing in between — perf: remove per-hit atomics from the shared counter, cache and DB hot paths #12870 (per-hit atomics removed from the shared counter/cache/DB hot paths, which is exactly what a warmer miss re-probes), perf(evm): front the EVM call pools with a per-thread free list #12905, perf: Iterators pool for archive index #12813, the flat-compression work. Cross-era percentages in the PR body are therefore not comparable to today's, in either direction, and the cached-baseline table in the PR comment is not a clean A/B. Only run 32733971643 is.

5. What would settle it

  • n=5 same-session A/B, low-variance mode: -f expb_env="EXPB_EVM_WARMUP=1" with -f additional_extra_flags="--JsonRpc.GasCap=1000000000000" drops realblocks CV to ~0.55%, which is enough to resolve a 1.6% delta rather than argue about it.
  • A 3-arm ablation in one dispatch: master / branch-with-promotion-disabled / branch. That prices whether the double-Keccak + re-decode promotion pays for itself at all, and it is a one-line change to build the middle arm.
  • dottrace=tracing for call counts (expect ~2× DecodeRlp and ~2× ValueKeccak.Compute per warmer node vs master), and the EventPipe .nettrace / timeline for contention on TryAcquireWarmerResolution — if the serialisation theory is right, that is where it shows.
  • Your own fallback (memoise path → rlp for the block at the RLP layer, no hash claim, no cross-block promotion) is worth putting in the same sweep. Given that (a) is worth ~0 and (c) is what fails the sync gate, a cheap RLP-level memo may simply dominate this design.

6. Incidental, on this tree

  • No correctness evidence exists for this code. Sync Master Validation 32745825843 failed all four jobs at Install Sedge environmentWait for sync was skipped, sync never ran. The sync evidence in the PR body is from fix(flat): isolate the trie warmer negative cache (InvalidStateRoot on Flat live-head sync) #12924's MissNodes tree, which is no longer in this branch.
  • Code Lint is red on SnapshotBundle.cs:12warning IDE0005: Using directive is unnecessary (using Nethermind.Trie.Pruning;, left over from the deleted PublishWarmedNode). One-line fix. Fix this →
  • Nethermind.Synchronization.Test [checked] failed one test, FastSync_skips_pre_eip7928_block_access_lists_over_eth71, on a 10m00s TaskCanceledException timeout; master's run in the same window was green. I have not classified it as flake vs. real — but this branch adds two unbounded spin waits (WaitForExclusiveLease, TryAcquireWarmerResolution), so a hang is a mechanism worth ruling out rather than assuming a flake. Worth one re-run.
  • The PR body still describes MissNodes and "a private TrieNode … never shared with the warmer". The tree does the opposite now: live readers reuse the warmer's instance once its resolution flag flips, and detachment happens at promotion. @benaadams already flagged this; it matters here because the body's validation section is describing a different design's results.

Bottom line: the degradation is not recovered by this tree, and on the evidence it cannot be recovered by any variant that keeps the warmer's node private — the gain lives in shared reuse + cross-block promotion, which is the same thing that broke consensus twice. This PR re-enables that mechanism behind a resolution protocol and a drain barrier, and the protocol's cost currently eats the gain. I would not merge it as a performance change on these numbers; if it is worth merging it is as a correctness change over master (which still carries #12877's predicate-guarded shared placeholder), and that case needs a Sync Master Validation run that actually starts.
· branch perf/flat-warmer-publish-resolved

kamilchodola and others added 2 commits August 24, 2026 22:15
A path-keyed persistence read can answer a warmer read with another version of
the node at that path, so the RLP hash check is expected to fail while the
warmer runs ahead of or behind the live reads. Resolve warm-up traversal with
TryResolveNode so such a node just ends the traversal unresolved and
unpublished, instead of throwing out to the warmer processor as an ERROR.
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review - check if anything outstanding is needed to be done before merging

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 7m 43s —— View job


Pre-merge check: what's still outstanding

  • Read the current diff vs origin/master (a383af4)
  • Re-derive the resolution protocol (IsWarmerOwned / _warmerResolvingMask / IsWarmerResolved) for publication safety
  • Status of every prior finding — mine and @benaadams's
  • CI state: lint, tests, Sync Master Validation
  • Fixed the Code Lint blocker and pushed (8d62166)
  • Posted 1 inline comment

The big news since my last pass: the correctness gate is now green on this exact tree. Everything else outstanding is small. Nothing new at Critical or High.


The gate that mattered is green

Sync Master Validation 32775387528 ran on a383af4 — this tree, not #12924's — and Wait for sync=success for both Sync mainnet (Flat) and Sync gnosis (Flat), followed by Check size of DB=success. That is the gate that caught #12793, #12877 and #12924, run against the design that is actually here. It's the first time this chain of work has had that.

Worth stating plainly because the PR body still hedges ("No new live-sync validation against the current master base has been run"): that is now out of date, and it undersells the evidence considerably.

Protocol re-derivation

I re-checked publication safety from scratch, since shared-instance reuse is precisely what broke twice before:

  • Ordering is correct. TryResolveWarmerOwnedNode does DecodeRlp (_nodeData) → WriteRlp (seqlock _rlpArray/_rlpSeqAndLength) → CompleteWarmerResolution(true). That last step is Interlocked.CompareExchange on _blockAndFlags — a full fence — and readers gate on Volatile.Read(ref _blockAndFlags) via IsWarmerResolved. Correct release/acquire pairing, so a reader that sees resolved sees both writes. No reader can observe the NodeType.Branch-with-null-_rlpArray window.
  • The hash check is on the right side of it. VerifyWarmerOwnedRlp (TrieNode.cs:691) runs before DecodeRlp, and resolved is only set on success — so a path-keyed read that answers with another version of the node at that path leaves the flag clear, and SnapshotBundle.cs:170/:301 keep it out of live reads. TrieNodeCache.Add then hashes again independently (:141) before promoting a detached copy. Verified twice, on both paths that can publish.
  • The _warmerResolvingMask spin terminates. A failed resolution clears resolving without setting resolved, so a spinner retries rather than parking forever; CompleteWarmerResolution is in a finally, so the bit can't leak. TryAcquireWarmerResolution returning false happens only for resolved, which is why return true there is right.
  • IsPlaceholder's removal is still safe, now for a different reason than last round: every writer to Nodes is commit, a persistence hit, or a warmer-owned node the gate filters. Confirmed by re-reading, not carried over.
  • CreateInlineChild propagating IsWarmerOwned means live readers descending a warmer-resolved branch get warmer-owned inline children. Those have RLP and a null Keccak, so VerifyWarmerOwnedRlp short-circuits and the protocol just adds one CAS per inline child on the live path. Correct; a small cost, not a hazard.
  • PatriciaTree.cs:997 (ResolveNodeif (!node.TryResolveNode(...)) return;) is confined to DoWarmUpPath, whose only two callers are FlatStorageTree.cs:129 and FlatWorldStateScope.cs:368. No HalfPath exposure — previously a stale warmer read escaped WarmUpPath's catch (TrieException) { …; throw; } and killed the prewarm task; now it just ends the traversal. Good change.
  • The shared RLP array between the transient's warmer node and the promoted detached copy (efb875f) is safe: PrepareRlp's bufferPool.SafeReturn(oldRlp) is reachable only when rlp.IsNull || IsDirty, and both nodes are non-dirty with non-null RLP. (Clone() produces a dirty aliasing node that can reach that SafeReturn — but Clone already aliases cached-node RLP on master, so that's pre-existing and out of scope here.)

Outstanding

Fixed by me and pushed — Code Lint was red. 8d62166 removes using Nethermind.Trie.Pruning; from SnapshotBundle.cs:12, left over from the deleted PublishWarmedNode. Every Metrics reference in the file is fully qualified and no other Trie.Pruning type is used unqualified, so the removal is safe. Job 97583450852 was failing on that single warning IDE0005 and nothing else.

Medium — the concurrency test never exercises a live read mid-resolution. Inline · @benaadams raised this and it's still open. Concurrent_owned_warmer_resolution_loads_once goes start.Set()loadStarted.Wait()allowLoad.Set() with nothing in between, so it pins "loads once" but not "invisible while resolving" — and the second property is the entire reason reusing the warmer's instance isn't a repeat of #12877. A regression that set _warmerResolvedMask before DecodeRlp/WriteRlp finished, or that weakened the two-flag gate, would pass the suite while letting block processing read a branch node with a null _rlpArray — a silently truncated trie, not an exception. Two lines inside the window the test already holds open close it deterministically; suggestion inline. This is the one Medium I'd want in before merge, because it's cheap and it guards the load-bearing invariant.

Low — Nethermind.Blockchain.Test [checked] is red, and it needs one re-run to classify. Job 97585787203: WillOnlyUnblockOnceHeadReachHighEnough failed on Assert.That(waitTask.Wait(TimeSpan.FromMilliseconds(500)), Is.True). A 500 ms timing assertion in blocking-verification code, nothing to do with the trie or flat, and the [no-intrinsics] variant of the same test passed in the same run. Reads as a runner-load flake — but this branch does add an unbounded SpinWait (TransientResource.WaitForExclusiveLease), so I'd rather see it green than assume. One re-run settles it.

Low — duplicate test. Unresolved_warmer_miss_is_not_promoted (line 107) is strictly subsumed by Unresolved_warmer_miss_does_not_reach_trie_node_cache(storage: false) (line 127) — same bundle, same assertion, plus two stronger ones. AGENTS.md test-duplication rule; just delete it. (@benaadams's point, still open.)

Low — the PR description now describes a design that isn't in the tree. Third round for this one, and it matters more than usual because the body's validation section is reporting a different design's results:

  • MissNodes no longer exists — TransientResource gained only WaitForExclusiveLease.
  • "A private TrieNode is built from the RLP … and published to Nodes" is inverted: live readers deliberately reuse the warmer's instance once IsWarmerResolved flips, and detachment happens at promotion.
  • "14 total" is stale (the file is now 21 test cases), and the sync/EXPB evidence cited predates both the hash check and the resolution protocol — while 32775387528 above is the run that actually validates this tree and isn't mentioned.

Not a blocker, restating for the record: the perf case is unchanged from yesterday's analysis. The latest comparison on this tree has realblocks at +1.82% AVG / +6.72% P99 against a cached master, and my same-session read of 32733971643 put it at +1.61% AVG with non-overlapping ranges. Two EXPB runs (32821805354, 32821825399) are queued now, so this may move. My view stands: this merges as a correctness change, not as an optimizationmaster still carries #12877's predicate-guarded shared placeholder resolved from an unverified path-keyed read, and this closes that. Worth un-ticking the "Optimization" box unless the queued A/B comes back positive.

Verdict

No Critical, no High. The correctness argument is sound and — for the first time in this chain — backed by a green flat mainnet + gnosis sync on the tree being merged. Before merge I'd want: the mid-resolution live-read assertion, a re-run of Nethermind.Blockchain.Test, the duplicate test deleted, and the description brought in line with the code. Lint is handled.
· branch perf/flat-warmer-publish-resolved

Left over from the removed PublishWarmedNode; every Metrics reference in
the file is fully qualified, so it fails Code Lint with IDE0005.

Co-authored-by: Kamil Chodoła <43241881+kamilchodola@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lves

Also drop the test subsumed by its parameterized successor and trim the
comments this PR added to one-liners.
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 8m 7s —— View job


Re-review: fix(flat): isolate warmer misses and safely publish verified reads

  • Read the current diff vs origin/master (HEAD 58bd117)
  • Verify the four items from the 07:43 pass landed
  • Re-derive the resolution protocol and the flag algebra from scratch
  • Establish whether the green sync run still covers HEAD
  • Check CI
  • Posted 2 inline comments

Everything I asked for last round is in, and one thing is stronger than I stated then: the green Sync Master Validation still covers the code that would be merged. No Critical, High, or Medium. Two Lows, both cosmetic-to-code-hygiene.


The sync gate covers HEAD, not just a383af4

Worth stating precisely, because I flagged the tree/evidence mismatch three times and it is now resolved rather than merely improved. Sync Master Validation 32775387528 ran on a383af4 with Wait for sync=success on both Sync mainnet (Flat) and Sync gnosis (Flat). Two commits have landed since. I diffed both:

  • 8d62166 — removes one unused using Nethermind.Trie.Pruning;.
  • 58bd117 — the new test plus the deleted duplicate; its three production-file hunks (TransientResource.cs, PatriciaTree.cs:997, TrieNode.cs:528) are comment text only, verified hunk by hunk.

So HEAD's production behaviour is identical to the tree that synced mainnet and gnosis clean. That is the gate that caught #12793, #12877 and #12924, and it is now green on the merge candidate.

Previous rounds — all closed

Item Status
Critical: hash asserted, not verified Fixed — VerifyWarmerOwnedRlp (TrieNode.cs:691) before DecodeRlp, and again independently at promotion (TrieNodeCache.cs:143)
Medium: decode failure changes the read's outcome Fixed — TryResolveWarmerOwnedNode returns false; PatriciaTree.cs:997 treats that as end-of-warm-up
Medium: GetOrAdd can evict the authoritative node Acknowledged with rationale
Medium: MissNodes footprint / GetSize() blind spot MootMissNodes no longer exists; the redesign removed the second ChildCache entirely
Medium: concurrency test never reads mid-resolution Fixed in 58bd117, and non-vacuously — see below
Low: duplicate Unresolved_warmer_miss_is_not_promoted Deleted
Low: description described a different design Rewritten to the resolving/resolved protocol
Code Lint red on IDE0005 Green — Code Lint 32824759261

On the new assertion: I checked it bites rather than passes by construction. At the point it runs, the first resolver is parked inside Load() holding _warmerResolvingMask, _warmerResolvedMask is clear, and _rlpArray is still null. FindStateNodeOrUnknown therefore fails the !IsWarmerOwned || IsWarmerResolved gate, DoFindStateNodeExternal misses (the stub only answers TryLoadStateRlp, not the decoded-snapshot lookup), and the caller gets a fresh Unknown. Drop the gate, or set resolved before DecodeRlp/WriteRlp, and Is.Not.SameAs(warmed) fails immediately. That is exactly the invariant the design rests on.

Protocol re-derivation (done fresh, not carried over)

  • Flag algebra is safe. Every mutator of _blockAndFlagsIsPersisted, IsBoundaryProofNode, Seal, MarkWarmerOwned, TryAcquireWarmerResolution, CompleteWarmerResolution — is a CAS retry loop over the whole byte, so the three new bits cannot be clobbered by a concurrent IsPersisted = true. The only raw writes are in constructors, before publication.
  • Ordering is release/acquire. DecodeRlpWriteRlp (seqlock) → CompleteWarmerResolution (Interlocked.CompareExchange, full fence); readers gate on Volatile.Read. No reader can see a decoded NodeType with a null _rlpArray.
  • A failed verification leaves the node clean. WriteRlp runs only after VerifyWarmerOwnedRlp passes, so a stale read leaves _rlpArray null and resolved clear — the node stays a placeholder rather than a half-materialised one, and a later resolver retries from scratch.
  • The resolving spin terminates. CompleteWarmerResolution is in a finally, so the bit cannot leak; a failed resolution clears resolving without setting resolved, so a spinner retries rather than parking.
  • WaitForExclusiveLease cannot hang at the call site. FlatDbManager.PopulateTrieNodeCache (:188-192) holds a lease when it calls Add, so _leases >= 1 on entry and the wait is for stale warmer readers to drain — each of which releases in a finally around a bounded find. SpinWait.SpinOnce escalates to Thread.Yield/Sleep, so it is not a hard spin on an oversubscribed box.
  • ThrowInvalidKeccak is unreachable on the production warmer path. The only warmer entries are FlatWorldStateScope.cs:368 and FlatStorageTree.cs:129, both WarmUpPathTryResolveNode; live readers only reach resolved warmer nodes (which return early from TryAcquireWarmerResolution); and the unresolved warmer-owned nodes they can reach are CreateInlineChild products with a null Keccak, where verification short-circuits. See the inline comment.
  • DoWarmUpPath's child walk does not reintroduce the throw. GetChildWithChildPath returns the child without resolving it (TrieNode.cs:966-1003); resolution happens at the top of the next loop iteration, through TryResolveNode. So a stale child ends the warm-up rather than escaping WarmUpPath's catch (TrieException) { …; throw; }.

Findings

Low — TrieNodeCache.cs:145 the keccak is null branch is unreachable, and it's the one path that would skip verification. Inline Every warmer-owned node in Nodes comes from CreateWarmerUnknownNode(hash); inline children (the only other MarkWarmerOwned caller) live in a parent's _nodeData and never enter Nodes. If it were reached it would promote unverified bytes under a null Keccak, which TryGet (:91) can never match — occupying a bucket, evicting its previous occupant, and charging _shardMemoryUsages for something permanently unreachable. Collapsing it to if (keccak is null || …) return null; removes code and makes "re-verified before promotion" unconditional.

Low — TrieNode.cs:528 the comment says the condition is benign; the helper it labels throws. Inline The tolerance lives in TryResolveWarmerOwnedNode and PatriciaTree.cs:997, not here. coding-style.md: a comment that contradicts the code is worse than none.

Low (informational, no action) — a permanently stale path re-reads persistence on every warmer visit. A node that fails verification stays warmer-owned and unresolved in Nodes, so the next WarmUpStateNode hit returns it and TryResolveNode re-issues the persistence read. The negative cache absorbs misses, not stale hits. On master this didn't happen only because the stale bytes were accepted — i.e. it's the bug's cost, correctly paid. Bounded by how often the flat DB genuinely holds another version at a warmed path, which is the multi-block-branch case; worth a glance in the A/B rather than a change.

Low (informational) — DoWarmUpPath is shared, so hash/HalfPath layouts also change. ResolveNodeTryResolveNode means a genuinely missing node during prewarm now silently ends the traversal instead of raising an enhanced TrieException. Warm-up is best-effort so I think this is the right default, but it does remove the only diagnostic signal a non-flat store had for a missing node during prewarm.

On merging

  • Correctness: settled, and now backed by a green flat mainnet + gnosis sync on the exact production code. master still carries fix(flat): restore the trie warmer negative cache without poisoning live reads #12877's predicate-guarded shared placeholder resolved from an unverified path-keyed read; this closes it.
  • Performance: unchanged from my 16:21 analysis and still the open question. The body reports −0.4% / −1.6% / −1.3% realblocks and 0.0% fusaka across four same-session A/Bs in both orderings — that's a genuinely better evidence base than the cached-baseline tables, and it reads as parity-to-slightly-positive rather than the +1.6% I measured on the older tree. Parity is a fine result for a correctness fix; I'd still leave the "Optimization" box as a judgement call rather than a claim the numbers compel.
  • CI: lint, formatting, spell check, Trivy, dependency review, build tools and stateless tests are green on HEAD. Build solutions, Nethermind tests (Flat DB), Nethermind extra test variants, Nethermind/Ethereum tests, Integration tests (E2E) and Sync PR Gate (Hoodi) are still running — worth confirming green before merge, since the last two rounds each had one unexplained red (Nethermind.Synchronization.Test, then Nethermind.Blockchain.Test) that never got classified.

Not done

dotnet build and dotnet test are refused in this job, so all of the above is static analysis; I did not reproduce the 20/20 SnapshotBundleWarmerTests or 475 Nethermind.Trie.Test results. Compile-level facts I checked by hand instead: Nethermind.Trie.Test already has InternalsVisibleTo (TrieNode.cs:22) so the new MarkWarmerOwned usage in StaleWarmerTrieStore resolves; RefCountingLease.Single == 1 matching WaitForExclusiveLease's comparison; and the TrieNode(NodeType, Hash256, CappedArray<byte>) overload setting IsPersisted for NodeType.Unknown.

Verdict: no Critical, High, or Medium. Two cosmetic Lows, neither blocking. My prior not-mergeable was on a Medium that the redesign has since made moot — I'd merge this on the green sync run once the pending test jobs land, treating it as the correctness fix it is.
· branch perf/flat-warmer-publish-resolved

Comment on lines +142 to +147
Hash256? keccak = source.Keccak;
if (keccak is not null && ValueKeccak.Compute(fullRlp.AsSpan()) != keccak) return null;

TrieNode detached = keccak is null
? new TrieNode(NodeType.Unknown, fullRlp)
: new TrieNode(NodeType.Unknown, keccak, fullRlp);

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 — the keccak is null branch is unreachable, and it is the one path here that would skip verification.

Every warmer-owned node that can appear in transientResource.Nodes comes from SnapshotBundle.CreateWarmerUnknownNode(hash), which is called only with a non-null hash from the four warmer find sites (SnapshotBundle.cs:193, :219, :327, :349). MarkWarmerOwned is otherwise reached only from CreateInlineChild, and inline children live inside a parent's _nodeData — they are never inserted into Nodes. So source.Keccak is non-null for every source that reaches this helper.

If it were reachable it would be the wrong behaviour twice over: the keccak is not null && guard on line 143 short-circuits, so unverified bytes get promoted; and the resulting node has Keccak == null, which TryGet (line 91, maybeNode.Keccak == hash) can never match — so it would occupy a bucket, evict whatever was there, and be charged to _shardMemoryUsages while being permanently unreachable.

Per the AGENTS.md preference for removing code over adding it, I'd collapse it to the invariant:

Suggested change
Hash256? keccak = source.Keccak;
if (keccak is not null && ValueKeccak.Compute(fullRlp.AsSpan()) != keccak) return null;
TrieNode detached = keccak is null
? new TrieNode(NodeType.Unknown, fullRlp)
: new TrieNode(NodeType.Unknown, keccak, fullRlp);
Hash256? keccak = source.Keccak;
if (keccak is null || ValueKeccak.Compute(fullRlp.AsSpan()) != keccak) return null;
TrieNode detached = new(NodeType.Unknown, keccak, fullRlp);

That also makes the "re-verified before promotion" claim unconditional, which is what the PR description says it is.

(The two catches below are likewise defensive-only — TryResolveNode on a non-warmer node already swallows RlpException internally, and the bytes are hash-verified before they get here — but they're cheap and I'd leave them.)

Comment on lines +528 to +530
// A path-keyed store serving another version of the node is staleness, not corruption.
[DoesNotReturn, StackTraceHidden]
void ThrowInvalidKeccak(in TreePath nodePath) => throw new TrieNodeException(

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 — the comment says the condition is benign, the code it labels throws.

.agents/rules/coding-style.md: "a comment that contradicts the code is worse than no comment". As written this reads as an explanation of why the mismatch is tolerated, sitting directly on the helper that raises TrieNodeException for it. The tolerance actually lives in the other overload (TryResolveWarmerOwnedNode at :616, if (!VerifyWarmerOwnedRlp(rlp)) return false;) and in PatriciaTree.cs:997.

Worth noting while you're here that this throw is unreachable on the production warmer path, which is why the comment reads oddly:

  • the only warmer entry points are FlatWorldStateScope.cs:368 and FlatStorageTree.cs:129, both WarmUpPathDoWarmUpPathTryResolveNode, never ResolveNode;
  • a live reader can only obtain a warmer-owned node with a Keccak once IsWarmerResolved is set (SnapshotBundle.cs:170/:301), and TryAcquireWarmerResolution returns false for a resolved node, so ResolveWarmerOwnedNode returns before reaching the check;
  • the warmer-owned nodes a live reader can reach unresolved are CreateInlineChild products, whose Keccak is null — so VerifyWarmerOwnedRlp short-circuits to true.

I'm not asking you to delete the branch (ResolveNode is public and symmetry with TryResolveNode is reasonable), just to reword so the comment describes what the throw means rather than why it doesn't matter — e.g. "The RLP does not hash to the requested node; the tolerant path is TryResolveNode, which reports this as a miss."

@kamilchodola kamilchodola added this to the 2.0.0 milestone Aug 25, 2026
kamilchodola and others added 2 commits August 25, 2026 10:46
…lution

A live reader that reaches a warmer-owned node through a shared parent's child
slot waits for the warmer's in-flight persistence read; SpinWait's Sleep(1)
escalation would add up to a millisecond of tail latency there, so spin and
yield only. Also cover the retirement drain with a regression test and drop a
vacuous assertion.

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

Out of the changed files scope:

The warm-up storage tree is rooted at the live tree's RootRef, which the live adapter created, so it is never warmer-owned. DoWarmUpPath does no FindCachedOrUnknown re-lookup at depth 0, so the root resolves through the plain TryResolveNode branch with no ValueKeccak.Compute check while TryLoadStorageRlp ignores hash. That writes possibly-stale RLP into the live storage root — the failure this PR fixes, on the one node the verification cannot reach.

// The warm-up traversal resolves this node through a path-keyed reader; require hash verification.
// src/Nethermind/Nethermind.State.Flat/ScopeProvider/FlatStorageTree.cs:59
_warmupStorageTree.RootRef = _tree.RootRef;
_warmupStorageTree.RootRef?.MarkWarmerOwned();

Comment thread src/Nethermind/Nethermind.Trie/TrieNode.cs
Comment thread src/Nethermind/Nethermind.Trie/TrieNode.cs
Comment thread src/Nethermind/Nethermind.State.Flat/TrieNodeCache.cs
Comment thread src/Nethermind/Nethermind.Trie/TrieNode.cs
Comment thread src/Nethermind/Nethermind.Trie/TrieNode.cs
@kamilchodola
kamilchodola merged commit dc72373 into master Aug 25, 2026
594 of 598 checks passed
@kamilchodola
kamilchodola deleted the perf/flat-warmer-publish-resolved branch August 25, 2026 10:25
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@wurdum on the out-of-scope root note: agreed it is the one node the verification cannot reach, but it is pre-existing (FlatStorageTree.cs:59 aliasing + the stock unverified resolve) and exposure is nil per block — a root miss means the persisted version is the parent-block version, which is current, and after commit _tree.RootRef is a new object while the warm-up tree keeps the old one. Marking the live root warmer-owned would push the live tree's own root (and its inline children) through the CAS protocol, with Commit/Clone interactions I have not traced, so I would rather take it as a separate issue than fold it into #12980.

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

Ran a six-reviewer pass. Starting with what matters most: the publication protocol is sound, and this
is not #12877 again.
That was traced independently twice, not assumed. _nodeData and _rlpArray are
both written before CompleteWarmerResolution's Interlocked.CompareExchange, readers Volatile.Read
the flag before touching either, so no reader can observe the resolved bit ahead of the data. The CAS
cannot grant two owners, and every co-resident writer (IsPersisted, Seal, MarkWarmerOwned) is a
full-byte CAS loop so no flag is lost. _blockAndFlags packs no block number despite the name, so bits
3-5 were genuinely free. The shared RLP array is a fresh allocation that is never pooled and never
mutated in place. A failed verification leaves _nodeData and _rlpArray both null, so there is no
half-decoded state. #12877 shared a mutable placeholder behind a predicate; this shares an
immutable-once-published node behind a verified flag, and the difference holds up.

The problem is that the PR's stated reason for existing does not survive checking.

The base already retained resolved warmer nodes

pr-12951-base's live gate is !TrieNodeCache.IsPlaceholder(node), which accepts any warmer node with
NodeType != Unknown, and its Add promoted that same object into the shared cache. Only unresolved
placeholders were dropped, and this PR drops those too. So against the tree that was benchmarked, this
does not restore retention - it adds a verification tax to retention that already worked.

Where the measured deltas plausibly come from instead, both unrelated to retention:

  1. DoWarmUpPath no longer throws per unresolvable path. On base every warmer traversal reaching a node
    absent from persistence threw, unwound, and hit EnhanceException, which formats the hex key and root
    hash into a message and rethrows - roughly 10-30 us each. Nodes absent from persistence are normal
    while persistence lags the head. At ~500 such paths per block that is 5-15 ms of warmer CPU removed.
  2. The live gate got cheaper. IsPlaceholder read node.FullRlp, i.e. a seqlock with two volatile reads
    and a possible spin; the new gate is one or two plain byte loads, on every trie node of every live
    state read.

On the numbers themselves: the spread across the three realblocks deltas (1.2 pp) is the same order as
the deltas (0.4-1.6%), so the data is its own noise estimate; three same-sign results at n=3 is p=0.125;
fusaka at 0.0% is consistent with no effect. And it cannot recover the +2-3% #12924 cost, because the
baseline is master rather than #12924 - that comparison was not run.

Four things I would want fixed

1. SpinOnce(sleep1Threshold: -1) in TryAcquireWarmerResolution. The -1 disables the escalation to
Thread.Sleep(1), so the loop degrades to Thread.Yield()/Sleep(0) forever. On Linux sched_yield
returns immediately when nothing of equal priority is runnable, so this is a 100% CPU busy-wait for the
full duration of a pread, on up to N-1 cores, with no timeout and no fallback. It is the only -1 in
the change - ReadRlp, WriteRlp and WaitForExclusiveLease all use plain SpinOnce(). It will not
appear in a warm A/B and will appear on a cold or IO-contended node as newPayload tail latency plus a CPU
floor that scales with the warmer worker count.

2. Block processing can reach an unresolved warmer node without passing the gate. The gate at
SnapshotBundle.cs:170/:301 covers the path-keyed transient lookup only. The warmer writes unresolved
warmer-owned children into _nodeData[i] of shared nodes via ResolveChildWithChildPath, and
DoWarmUpPath passes keepChildRef: true so UnresolveChild does not clear the slot. Block processing
legitimately takes the parent - the new gate permits it, and Resolved_warmer_node_is_reused_by_live_reads_and_promoted_detached
asserts it - descends to the child, and gets it with no gate. It then calls the throwing
ResolveNode, which lands in ResolveWarmerOwnedNode. So the block thread can both spin on warmer I/O
and take ThrowInvalidKeccak, which TrieWarmer.HandleJob catches for the warmer and nothing catches
here. Still better than base, which decoded the mismatched RLP silently - but claim 3 in the description
should not be read as "block processing never touches an unresolved warmer node", because it does.

3. A failed verification is not memoised, and the multiplier is per warmed key. On failure FullRlp is
never written, the resolved bit stays clear, and the node stays in the transient under the requested
hash. The next warmer traversal gets the same instance back, re-enters TryResolveWarmerOwnedNode,
re-issues the flat-DB read and recomputes the Keccak. Base decoded the stale bytes once and
NodeType != Unknown short-circuited every later visit. Level-1 state nodes are traversed by every warmed
key, so this moves from once-per-node to once-per-warmed-key - order 100-200x on shallow nodes. Bit
0b0100_0000 is free; a sticky unresolvable flag that TryAcquireWarmerResolution checks is about five
lines, and the transient is per-block so the negative result expires on its own.

4. The test that guards the #12877 failure mode parks at the wrong point.
Concurrent_owned_warmer_resolution_loads_once blocks inside TryLoadStateRlp, i.e. before
VerifyWarmerOwnedRlp, before DecodeRlp, before WriteRlp. At that instant the node is
NodeType.Unknown with empty RLP, which is exactly what the old IsPlaceholder predicate already
excluded - revert the gate to !IsPlaceholder(node) and the test still passes. The window the new gate
actually closes is later: DecodeRlp assigns _nodeData, so NodeType stops being Unknown while
FullRlp is still empty and the resolved bit is still clear. That is the #12877 shape and nothing parks
there. A test that puts a decoded-but-unpublished warmer node in the transient and asserts
FindStateNodeOrUnknown does not return it would fail under the old predicate and pass under the new
one, which is the revert sensitivity currently missing.

Smaller

  • Single-flight does not hold across instances. ChildCache.GetOrAdd is check-then-act, so two warmer
    threads warming accounts with a shared prefix create two distinct placeholders for the same path and
    both issue a persistence read. The flag protocol only serialises threads that already share an
    instance. The concurrency tests hand all four tasks the same instance, so they never exercise it.
    _count++ on the same path is a non-atomic read-modify-write.
  • WaitForExclusiveLease drains lookups, not resolutions - the lease is released in
    FindStateNodeOrUnknownForTrieWarmer's finally, and TryResolveNode runs after that. So promotion
    finds IsWarmerResolved == false and silently drops exactly the coldest, slowest nodes, which is the
    retention this PR is for. The name also promises exclusivity it cannot provide: nothing stops a new
    lease one instruction later.
  • The TrieNodeCache.cs:145 keccak is null branch is unreachable (warmer-owned nodes in the transient
    all come from CreateWarmerUnknownNode(hash), which cannot have a null hash on a sealed node), and it
    is the one branch that would publish without verification. if (keccak is null) return null; fails
    closed instead.
  • The comment at TrieNode.cs:530 says a path-keyed store serving another version is staleness rather
    than corruption, sitting on the [DoesNotReturn] helper that throws. The benign handling is 90 lines
    away at the TryResolve twin.
  • The description's reason for the DoWarmUpPath change is unfounded: TrieWarmer.HandleJob already
    caught both TrieNodeException and NodeHashMismatchException with empty bodies, so nothing that
    previously surfaced is now hidden. The change is right; the justification is not.
  • The gate that stands between the warmer and block processing lost its name and its documentation.
    !TrieNodeCache.IsPlaceholder(node) carried an XML doc spelling out why such a node must neither enter
    the shared cache nor satisfy a live read; it is now an unnamed boolean duplicated at two call sites,
    reading _blockAndFlags twice. And the ordering rule the whole protocol rests on - resolved bit last,
    acquire-read first - is documented nowhere, while ReadRlp and WriteRlp in the same file both carry
    explicit seqlock and ARM64 notes.

One design question worth answering before this lands

The root fact is a contract violation in the flat layer: ITrieNodeResolver.TryLoadRlp(path, hash, flags)
promises the RLP of that hash, and ReadOnlySnapshotBundle.TryLoadStateRlp never reads hash. This PR
repairs that by lifting hash verification into TrieNode, behind a new per-node flag, plus an
InternalsVisibleTo grant. Verifying in StateTrieStoreWarmerAdapter.TryLoadRlp and
StorageTrieStoreWarmerAdapter.TryLoadRlp instead is about three lines, computes the same Keccak the
same number of times, and the existing TryResolveNode already implements "failed verification leaves it
unresolved" exactly. Gating live reads on the already-public NodeType != NodeType.Unknown would also
avoid creating the decoded-but-unpublished window in the first place, since the standard resolver writes
RLP before the decode while the new warmer copy inverts that order.

That would take most of the 223 lines out of TrieNode.cs - a type every layout, sync healing, snap
serving and the visitor go through - and leave the flat-specific concern in the flat assembly. What it
gives up is single-flight, which per the point above does not currently work across instances anyway, and
which the A/B cannot separate from the two mechanisms that are actually producing the numbers.

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.

5 participants