Skip to content

fix(flat): harden the warmer resolution protocol (#12951 follow-ups) - #12980

Draft
kamilchodola wants to merge 1 commit into
masterfrom
perf/flat-warmer-followups
Draft

fix(flat): harden the warmer resolution protocol (#12951 follow-ups)#12980
kamilchodola wants to merge 1 commit into
masterfrom
perf/flat-warmer-followups

Conversation

@kamilchodola

Copy link
Copy Markdown
Contributor

Changes

Follow-ups from @wurdum's review of #12951 (hardening of the warmer resolution protocol; no change to the design).

  • Keep unresolved warmer placeholders out of the shared node graph. ResolveChildWithChildPath (both copies) memoizes a warmer-owned child into its parent's slot only once it is resolved. Live traversals (GetNew/SetNew) take children straight from the parent's _nodeData without going through SnapshotBundle's gate, and the live storage root is aliased into the warm-up tree (FlatStorageTree.cs:59), so this was a second door into a live read. Exposure was nil by lifecycle (the bundle is per block, so a mid-block warmer miss is persistence-backed and the path-keyed read is current); this makes it nil by construction.
  • Write the RLP before decoding in both warmer resolve paths, matching ResolveUnknownNode. Verification has already bound the bytes to the requested hash, so this is free; an undecodable read is then kept and not re-read from RocksDB.
  • Keep the structural hash-only-Unknown predicate in TrieNodeCache.Add alongside the warmer-owned check. No producer writes that shape into the transient today (PatriciaTree.Commit gates on FullRlp.Length >= 32), but the persistence writers skip it defensively in six places and the cost is one check on the background populator thread.

Not taken from the review, with reasons on the threads: a _warmerResolveFailedMask (mismatches are rare and confined to lagging jobs, and a terminal failed state would make the void ResolveWarmerOwnedNode return silently through the slot door); moving IsPersisted after verification (TrieNode(NodeType.Unknown, hash) already sets it in the constructor, so the flag is set by construction for every hash-only Unknown node on master too); marking the live storage root warmer-owned (pre-existing, exposure nil per-block, and it would push the live tree's own root through the CAS protocol — separate issue).

Validation

  • Nethermind.Trie.Test 477 passed / 12 skipped; Nethermind.State.Flat.Test 993 passed / 10 skipped (the 14 TearDown: IOException … arena_0000.bin failures are the known Windows arena file-lock artefact).
  • In flight on the pre-rebase tip 49f897aee4 (same diff for the touched files), against the fix(flat): isolate warmer misses and safely publish verified reads #12951 image as baseline: EXPB realblocks A/B amd64 (baseline first) / arm64 (this branch first), run_count=3, and Sync Master Validation Flat mainnet + gnosis. Draft until those are green.

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

New tests: an unresolved warmer placeholder is re-looked-up rather than memoized, and memoized once resolved (TrieNodeTests); an undecodable warmer read is kept and the resolver is called once; a hash-only Unknown node in the transient is not promoted (SnapshotBundleWarmerTests).

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

- Memoize a warmer-owned child into its parent's slot only once it is
  resolved, so live traversals never reach an unverified placeholder
  through the shared node graph instead of the SnapshotBundle gate.
- Write the RLP (and mark persisted) before decoding, matching the stock
  resolve order; an undecodable read is then kept and not re-read.
- Keep the structural hash-only-Unknown predicate in TrieNodeCache.Add
  alongside the warmer-owned check.
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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


Review: hardening the warmer resolution protocol

No Critical, High, or Medium findings. All three changes hold up under the checks below; four Low-severity notes, two posted inline.


What I verified (rather than took on trust)

1. The child-memoization gate (TrieNode.cs:1526-1533, 1708-1715)

  • A null child slot is always re-resolvable. The gated branch is only reachable when the parent's RLP is non-null (if (rlp.IsNull) { childOrRef = data; } short-circuits first), and no code path anywhere clears an already-written RLP — every WriteRlp call site (TrieNode.cs:450, 506, 579, 628, 785) is a resolve/encode path that writes real bytes. So leaving _nodeData[i] null yields exactly the state UnresolveChild already produces on a persisted parent, and re-resolution is deterministic (same parent RLP → same child keccak). No silent-missing-child hazard.
  • Memory ordering is intact. A live reader that picks a memoized resolved warmer child out of _nodeData still goes through ResolveNodeTryAcquireWarmerResolution's Volatile.Read(ref _blockAndFlags), which pairs with the writer's Interlocked.CompareExchange in CompleteWarmerResolution. The child's decoded _nodeData/_rlpArray are therefore visible with proper acquire semantics — the release fence isn't missing, it's the resolved bit.
  • Memoizing a resolved warmer child is still sound. VerifyWarmerOwnedRlp binds the bytes to Keccak, and both warmer adapters throw NodeHashMismatchException when node.Keccak != hash (StateTrieStoreAdapter.cs:17-21, 48-52), so the memoized child's keccak equals the hash the parent's RLP references. Content-correct regardless of which tree published it.
  • Cost is bounded to one extra probe per slot. IsWarmerOwned is set in exactly two places (SnapshotBundle.cs:222 placeholders, TrieNode.cs:1490 inline propagation), so the gate only fires on a warmer miss. DoWarmUpPath resolves the child on the next loop iteration, so the second visit to that slot memoizes. The repeated TryLoadRlp on a permanently-unresolvable child is pre-existing (the old code memoized the placeholder and still re-called TryResolveNode on every descent).
  • No fourth door. TryResolveStorageRoot (TrieNode.cs:1443-1446) is the other site that memoizes a FindCachedOrUnknown result into shared node data (data.StorageRoot), but its callers are the visitors / PrunePersistedRecursively / commit paths — DoWarmUpPath never reaches it, and the state warm-up tree isn't root-aliased the way FlatStorageTree is. ResolveAllChildBranch (:1598) and the childOrRef is Hash256 fallbacks (:992, :1751) don't write the slot at all.

2. Write-RLP-before-decode (TrieNode.cs:501-511, 624-633)

The new state — an unresolved warmer node that has RLP — doesn't leak anywhere:

  • TrieNodeCache.AddTryMaterializeResolvedWarmerNode checks IsWarmerResolved before looking at FullRlp (TrieNodeCache.cs:137), so a kept-but-undecodable read is still not promoted.
  • FindStateNodeOrUnknown / FindStorageNodeOrUnknown gate on !IsWarmerOwned || IsWarmerResolved (SnapshotBundle.cs:170, 301).
  • The six persistence writers that key off FullRlp.Length == 0 && NodeType == Unknown iterate snapshot.StateNodes/StorageNodes, not the transient — warmer nodes only ever enter the transient — so retaining RLP can't change what gets persisted.

loaded is behaviourally equivalent to the old if (!HasRlp) (the CAS makes resolution exclusive, and WriteRlp is last-writer-wins over a seqlock anyway), and moving IsPersisted = true past verification is a no-op given TrieNode(NodeType.Unknown, keccak) sets it in the ctor (:344-352) — your PR note checks out.

3. The placeholder gate (TrieNodeCache.cs:169, 214)

  • No NRE risk: CappedArray<T>.Length => _length, which is 0 for a null array, so FullRlp.Length is safe on a hash-only node.
  • Genuinely unreachable today, as you say: SnapshotBundle.SetStateNode (:404-413) is the only non-warmer transient producer and it rejects unsealed nodes.
  • Worth noting it's strictly better than before rather than merely defensive: AddToCacheWithHashCode does Interlocked.Exchange on the bucket, so a placeholder would have evicted a good cached node from that bucket. Skipping it preserves the existing entry.

4. Tests

  • cache.Add(rented) doesn't deadlock — WaitForExclusiveLease spins until _leases == RefCountingLease.Single, which a freshly checked-out resource already satisfies.
  • The negative assertion isn't vacuous: TryGet matches on maybeNode.Keccak == hash, so a promoted placeholder would be found.
  • keepChildRef: true in the memoization test is load-bearing — without it the pruning trick could UnresolveChild a persisted child and muddy the lookup counts. Good.

Low-severity notes

# Where Note
1 TrieNodeCache.cs:214 IsPlaceholder is the 7th copy of this predicate in the assembly — consider one shared helper (inline)
2 TrieNode.cs:1531 The gate skips the default: inline-child branch; safe, but the comment reads as if it covers all children (inline)
3 TrieNode.cs:624-633 After a failed decode the kept RLP means each later attempt re-runs ValueKeccak.Compute over the full RLP and re-attempts the decode. Still a net win (the RocksDB read is gone), and it's precisely the case the declined _warmerResolveFailedMask would have short-circuited — fine as is, just noting the residual.
4 TrieNodeCache.cs:169 Style only: if (shard[j].node is not { } source || (!source.IsWarmerOwned && IsPlaceholder(source))) continue; packs a null-test and a policy test into one condition. Two statements would read closer to the surrounding code.

Caveat on validation

dotnet build / dotnet test are not permitted in this environment, so I could not independently execute the new tests — I reviewed them statically against the production code and they look correct (including that [0xc2, 0x80, 0x01] reaches HexPrefix.FromBytes on an empty span, which is why the IndexOutOfRangeException catch in TryResolveWarmerOwnedNode is what makes that test's "kept, not re-read" assertion meaningful). Your reported 477/993 runs are the authority here.
· branch perf/flat-warmer-followups

}

// A hash-only Unknown node is not authoritative; the persistence writers skip the same shape.
private static bool IsPlaceholder(TrieNode node) => node.NodeType == NodeType.Unknown && node.FullRlp.Length == 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low (DRY). This is now the 7th copy of the same predicate in this assembly — PersistedSnapshotBuilder.cs:88,109, PersistedSnapshotUtils.cs:54,64,123,133 all spell out kv.Value.FullRlp.Length == 0 && kv.Value.NodeType == NodeType.Unknown, and PersistenceManager.cs:520-527,545-552 write the same test as a nested if. Since you're introducing a named helper anyway, consider making it internal static (e.g. on a TrieNodeExtensions/FlatTrieNodes helper) and reusing it at those sites, so the "skip the hash-only placeholder shape" rule has one definition.

Not blocking — the repo rule also says don't over-extract trivial one-liners, so this is a judgment call. But a 7-way duplicated invariant is the case where the named helper pays for itself.

data = childOrRef = child;
childOrRef = child;
// An unresolved warmer placeholder is not authoritative, so it stays out of the shared node graph.
if (!child.IsWarmerOwned || child.IsWarmerResolved) data = child;

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 (comment precision). The gate is correct, but it only covers the case 160: hash-reference branch — the default: (inline child) branch a few lines below still does data = childOrRef = child unconditionally, and CreateInlineChild propagates MarkWarmerOwned() from a warmer-owned parent (line 1488-1490). So an unresolved warmer-owned inline child can still be memoized into a shared parent slot, which reads as an oversight next to a comment that says unresolved warmer placeholders "stay out of the shared node graph".

It's safe as written — an inline child's bytes come out of the parent's own RLP, it has no Keccak (so VerifyWarmerOwnedRlp is trivially true), and any traversal would build the identical node — so there is nothing to isolate. Worth one clause acknowledging that (e.g. "…inline children carry the parent's own bytes and need no isolation") so the asymmetry between the two branches is deliberate on its face.

@github-actions

Copy link
Copy Markdown
Contributor

EXPB Benchmark Comparison

Run: View workflow run

superblocks

Scenario: nethermind-flat-superblocks-perf-flat-warmer-followups-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 845.30 835.14 +1.22%
MEDIAN (ms) 817.0 801.2 +1.97%
P90 (ms) 1015.2 986.8 +2.88%
P95 (ms) 1131.1 1198.5 -5.62%
P99 (ms) 2855.6 2745.7 +4.00%
MIN (ms) 542.6 540.4 +0.41%
MAX (ms) 2855.6 2745.7 +4.00%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 1582.90 1727.56 -8.37%
MEDIAN (ms) 1036.18 1241.35 -16.53%
P90 (ms) 2901.12 3513.56 -17.43%
P95 (ms) 3664.48 3748.99 -2.25%
P99 (ms) 5018.45 4934.63 +1.70%
MIN (ms) 636.94 646.07 -1.41%
MAX (ms) 8463.64 8201.40 +3.20%

realblocks

Scenario: nethermind-flat-realblocks-perf-flat-warmer-followups-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 19.85 19.70 +0.76%
MEDIAN (ms) 17.2 17.3 -0.58%
P90 (ms) 34.0 32.9 +3.34%
P95 (ms) 39.3 39.1 +0.51%
P99 (ms) 65.3 63.1 +3.49%
MIN (ms) 0.2 0.3 -33.33%
MAX (ms) 183.1 183.5 -0.22%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 23.82 23.27 +2.36%
MEDIAN (ms) 20.40 20.59 -0.92%
P90 (ms) 37.28 36.86 +1.14%
P95 (ms) 43.43 43.84 -0.94%
P99 (ms) 69.45 66.59 +4.29%
MIN (ms) 0.66 1.69 -60.95%
MAX (ms) 430.33 275.37 +56.27%

fusaka

Scenario: nethermind-flat-fusaka-perf-flat-warmer-followups-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 28.23 28.44 -0.74%
MEDIAN (ms) 25.6 26.0 -1.54%
P90 (ms) 44.1 44.3 -0.45%
P95 (ms) 54.7 52.8 +3.60%
P99 (ms) 73.3 75.0 -2.27%
MIN (ms) 4.0 4.2 -4.76%
MAX (ms) 343.0 323.2 +6.13%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 35.33 36.09 -2.11%
MEDIAN (ms) 31.38 31.30 +0.26%
P90 (ms) 53.56 53.07 +0.92%
P95 (ms) 64.04 62.42 +2.60%
P99 (ms) 92.53 108.85 -14.99%
MIN (ms) 5.55 5.68 -2.29%
MAX (ms) 615.27 522.91 +17.66%

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