core, miner, consensus/bor, eth, triedb/pathdb: pipelined state root computation for block import - #2180
core, miner, consensus/bor, eth, triedb/pathdb: pipelined state root computation for block import#2180pratikspatil024 wants to merge 80 commits into
Conversation
…oved the post tx execution buffer time
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.
Tip: disable this comment in your organization's Code Review settings.
Code ReviewFound 6 issues: 4 bugs and 2 security concerns. Bugs
Security Concerns
|
Code ReviewFound 5 issues in miner/worker.go and miner/pipeline.go. Checked for bugs and CLAUDE.md compliance. 1. Bug: writeElapsed always ~0ns (miner/worker.go L1116-L1123) writeElapsed := time.Since(writeStart) is computed immediately after writeStart := time.Now(), before either WriteBlockAndSetHeadPipelined or WriteBlockAndSetHead executes. writeBlockAndSetHeadTimer always records ~0, and workerMgaspsTimer (line 1148) reports inflated MGas/s. Fix: move writeElapsed := time.Since(writeStart) to after the if/else block. 2. Bug: nil pointer dereference (miner/pipeline.go L379-L384) When chainHead is nil, the || short-circuits into the if-body, but chainHead.Number.Uint64() in log.Error dereferences nil and panics. Per CLAUDE.md: No panics in consensus, sync, or block production paths. Fix: split into two if-checks. 3. Bug: unchecked type assertion (miner/pipeline.go L335-L341) borEngine, _ := w.engine.(*bor.Bor) discards the ok boolean. If w.engine is not *bor.Bor, borEngine is nil and borEngine.AssembleBlock(...) panics. The same assertion at line 96 correctly checks ok. Fix: check ok and return early. 4. Bug: goroutine leak on 5 return paths (miner/pipeline.go L293-L345) initialFillDone channel (line 293) goroutine is not drained on return paths at lines 345, 357, 371, 373, 383. Only WaitForSRC error (line 331) and happy path (line 390) drain it. Fix: defer drain after line 293. 5. Bug: trie DB race after SpawnSRCGoroutine (miner/pipeline.go L206-L229) SpawnSRCGoroutine called at line 213 launches a goroutine doing CommitWithUpdate. If StateAtWithFlatDiff fails (line 219) or GetHeader returns nil (line 228), fallbackToSequential does IntermediateRoot inline on the same parent root concurrently. The comments at lines 206-211 identify this as causing missing trie node / layer stale errors but only guard the Prepare() case. Fix: WaitForSRC() before fallbackToSequential, or move spawn after preconditions. |
Codecov Report❌ Patch coverage is ❌ Your patch check has failed because the patch coverage (87.85%) is below the target coverage (90.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## develop #2180 +/- ##
===========================================
+ Coverage 54.23% 55.19% +0.96%
===========================================
Files 908 912 +4
Lines 162242 165877 +3635
===========================================
+ Hits 87985 91561 +3576
- Misses 68818 68849 +31
- Partials 5439 5467 +28
... and 27 files with indirect coverage changes
🚀 New features to boost your workflow:
|
I am okay with the idea of removing the remaining 100ms. We already reduced this buffer from 500ms to 100ms in v2.7.1, and from what we have seen so far, this remaining time looks small enough that removing it seems reasonable. My main concern is not the removal of the 100ms itself. My concern is the cost of pipelining SRC with the next block production. In other words: by doing SRC in parallel with block building, how much do we impact SRC time itself? Do we expect SRC to remain roughly the same, or does it become meaningfully slower because it is now competing with the next block production? That is the part I would like to understand better. I think this is basically a TPS vs finality question:
So I am supportive of the direction, but I think the key question is still: How much TPS do we gain, and how much finality do we lose, if any, by making SRC fully pipelined with block production? If the impact on SRC time is only slight, then the tradeoff is probably clearly worth it. But if SRC time increases materially once it is pipelined with block production, then we should make that tradeoff explicit |
I think SRC will be roughly the same, because the time consuming part, trie nodes prefetching, is already running at the same time with tx execution today, and this PR doesn't change this behavior. |
…r block import Overlap SRC(N) with execution of block N+1 on importing/RPC nodes. After executing block N, defer IntermediateRoot + CommitWithUpdate to a background SRC goroutine and immediately proceed to block N+1 using a FlatDiff overlay for state reads. Cross-call persistence allows the SRC to run across insertChain boundaries. Key changes: - Pipeline path in insertChainWithWitnesses with ValidateStateCheap - FlatDiff overlay in StateAt, StateAtWithReaders, PostExecutionStateAt - Path DB reader chained fallback for concurrent layer flattening - Trie-only reader for SRC witness generation (no flat reader bypass) - WIT handler waits for pipelined witness before returning empty - WitnessReadyEvent for announcing witnesses to stateless peers - PropagateReadsTo in checkAndCommitSpan for witness completeness - Feature gated: --pipeline.enable-import-src
Adds TestPipelinedImportSRC_SelfDestruct to verify that the FlatDiff Destructs check in getStateObject correctly handles self-destructed contracts during pipelined import.
Two fixes for prefetcher errors during pipelined state root computation:
1. Storage root mismatch: FlatDiff accounts had storage roots from block
N's post-state, but the prefetcher's NodeReader was at the committed
parent root (grandparent). Add prefetchRoot field to stateObject that
stores the grandparent's storage root, read from the flat state reader
when loading from FlatDiff. Use it consistently across all prefetcher
interactions.
2. Layer stale during trie node resolution: SRC's cap() flattens diff
layers concurrently with prefetcher trie walks. Add nodeFallback to
reader.Node(), mirroring the existing accountFallback/storageFallback
pattern — retries via the current base disk layer on errSnapshotStale.
A series of fixes for pipelined SRC under EIP-2935/BLOCKHASH aborts and
abort-heavy devnet load:
1. Skip pipeline pre-Rio.
Pre-Rio speculative Prepare walks unsigned speculative headers and can hit
ecrecover failures on zero-seal Extra data. Disable pipelined SRC before
Rio so the miner stays on the safe sequential path there.
2. Move slot waiting fully to Seal and keep abort rebuilds in-slot.
The miner now always builds block bodies early and uses the slot for tx
selection, while Bor holds propagation until the target time in Seal().
Abort-recovery headers carry a miner-local AbortRecovery flag so late
speculative rebuilds stay in-slot instead of getting pushed to the next
slot by minBlockBuildTime.
3. Isolate block-build timeout state per build environment.
Sequential builds and speculative fills previously shared a worker-global
timeout flag, so one build's timer could interrupt another build's tx
selection. Move timeout state onto each environment and make timer cancel
stop the timer without poisoning the build as timed out.
4. Improve speculative fill behavior and fix DAG metadata on refill.
Speculative blocks now take a late refill pass when they are still under
about 75% full by gas and there is at least 300ms left before the slot,
not only when fully empty. Keep tx dependency DAG state on the block
environment across refill passes so multi-pass speculative fills do not
restart dependency indices from zero and drop metadata with
non-sequential transaction index errors.
5. Harden abort recovery and mined-block propagation.
After speculative aborts, requeue normal work through the standard worker
path instead of re-entering commitWork recursively. On the networking
side, mined inline blocks now still announce correctly when witness data
is already cached but the async block write is not yet visible in the DB.
6. Add regression coverage and clean up logs.
Add tests for Bor timing behavior, speculative refill decisions,
per-build interrupt isolation, DAG metadata persistence across refill
passes, cached-witness announcement, and BLOCKHASH(N) abort-flag
behavior. Also remove duplicate EIP-2935 abort logs and fix negative
seal-delay logging so slightly-late blocks no longer print huge wrapped
unsigned delays.
Wires a complete metrics suite for A/B comparing pipelined vs non-pipelined
import and block production on mainnet.
New pipelined metrics (import):
- chain/imports/pipelined/{hit,miss,root_mismatch,enabled}
- chain/imports/witness_ready_end_to_end — apples-to-apples end-to-end timer,
fires in both modes (primary A/B KPI)
New pipelined metrics (build):
- worker/pipelineSpeculativeCommitted, pipelineSRCWait, pipelineSealDuration
- worker/pipelineAnnounceEarlinessMs (signed ms — PIP-66 earliness signal)
- worker/pipelineSpeculativeAborts/{blockhash,src_failed,fallback}
- worker/build_to_announce — producer-side end-to-end, both modes
- worker/pipeline/enabled
Parity wiring for legacy metrics so dashboards work in both modes:
- chain/inserts, account/storage read + hash + update + commit timers,
snapshot/triedb commits, stateCommitTimer, blockBatchWriteTimer,
witnessEncode/DbWrite — emitted from the pipelined branch (main statedb or
SRC goroutine's tmpDB as appropriate)
- worker/writeBlockAndSetHead — emitted from inlineSealAndBroadcast's async
write goroutine
- pipelineAnnounceEarlinessMs and pipelineSpeculativeCommittedCounter also
emitted from resultLoop for the sealBlockViaTaskCh path
Throughput and overlay observability:
- chain/{gas_used_per_block,txs_per_block,mgasps} + chain/witness/size_bytes
- worker/chain/{gas_used_per_block,txs_per_block}
- state/flatdiff/{account_hits,storage_hits} — FlatDiff overlay effectiveness
Metrics that have no clean pipelined semantic (chain/validation, chain/write,
worker/commit, worker/finalizeAndAssemble, worker/intermediateRoot) are left
unemitted in pipelined mode with inline comments documenting the reason and
pointing to the closest pipeline equivalent.
Code ReviewFound 3 issues in
|
Inline Review CommentsSince inline comments could not be posted via the review API, here are the detailed findings with line references: Issue 1 (HIGH): Missing The non-pipelined path (lines 3618-3631) calls Fix: Add the same per-block CLAUDE.md: blockchain-security.md and consensus-security.md Issue 2 (HIGH):
Fix: Acquire CLAUDE.md: security-common.md — "Shared mutable state protected by mutex or atomic operations" Issue 3 (HIGH):
Fix: Replace CLAUDE.md: security-common.md — "Error values checked — never discard errors with _ in security-sensitive paths" |
…ions for diffguard compliance
Decompose large pipelined-src-authored functions into focused helpers so
every function owned by this branch sits under diffguard's 50-line /
complexity-10 limits. Pure structural refactor — no behavior change.
miner/pipeline.go:
- commitSpeculativeWork (599) → orchestrator (35) + specSession struct
with ~18 methods (setupInitial, waitForSRCAndSealBlockN, runOneIteration,
prepareNextIteration, sealCurrentAndAdvance, shiftToNext, etc.)
- inlineSealAndBroadcast (100) → 35 + sealViaPrivateChannel,
rebindReceiptsToSealedBlock, announceInlineSealedBlock
- commitPipelined (59) → 37 + buildSpeculativeReq, spawnSRCForFinalBlock
- sealBlockViaTaskCh (52) → 48 (reuses spawnSRCForFinalBlock)
miner/worker.go:
- fillTransactions (59) → 47 + commitTxMaps
- makeEnv (51) → 38 + resolveStateFor
- updateTxDependencyMetadata (68) → 32 + buildTxDependencyArray
Pre-existing develop functions where pipelined-src had grown the body
are reduced back close to or below their develop size by extracting the
added branches:
- commitWork (67 → 36) via clearPendingWorkOnExit + maybeStartPrefetch
- resultLoop (191 → 124; develop was 123) via emitExecutionMetrics,
emitCommitMetrics, writeTaskBlock, announceTaskBlock
- mainLoop (135 → 120; develop was 116) via handleSpeculativeWork
- buildAndCommitBlock (93 → 83; develop was 80) via submitForSealing
core/state/statedb.go:
- CommitSnapshot (95, complexity 40) → 30 + captureMutation,
captureObjectStorage, captureReadOnlyAccount, captureNonExistentRead
- ApplyFlatDiffForCommit (49, complexity 20) → 16 + applyFlatMutation
- ApplyFlatDiff (36, complexity 11) → 13 + applyFlatAccountOverlay
- TouchAllAddresses (25, complexity 11) → 12 + touchAddressAndStorage,
mutatedStorageKeys
core/blockchain.go:
- SpawnSRCGoroutine (127, complexity 35) → 13 + runSRCCompute,
openSRCStateDB, preloadFlatDiffReads, emitSRCStateDBMetrics,
encodeAndCachePendingWitness
- writeBlockAndSetHeadPipelined (108, complexity 29) → 16 +
writePipelinedBlockBatch, writeBorStateSyncLogs, resolveWriteStatus,
emitPipelinedWriteEvents
- handleImportTrieGC (52, complexity 16) → 21 + capTrieIfDirty,
maybeFlushChosen, dereferenceUpTo
- waitForPipelinedWitness (complexity 11) → 9 + waitForPendingSRCWitness,
pollWitnessCache
core/evm.go:
- SpeculativeGetHashFn (complexity 12) → 17 + newPendingBlockNResolver
core/blockchain.go insertChainWithWitnesses pipelined branch (had grown
+222 lines on top of develop's 452) → +42 via buildPipelineImportOpts,
persistPipelinedImport, collectPrevImportSRCIfAny, emitStateSyncFeed,
runImportAutoCollection, verifyImportSRCRoot, publishImportWitness,
emitPipelinedImportParityMetrics.
core/blockchain.go ProcessBlock pipelined branches (+22 lines) → +4
via pipelineReaderRoot, applyFlatDiffOverlayToAll, validateStateForPipeline.
eth/peer.go:
- doWitnessRequest (pipelined-src pushed from 38 → 65) → 32 +
awaitWitnessResponse extracting the goroutine body
eth/handler_wit.go:
- handleGetWitness (pipelined-src pushed from 70 → 91) → 66 +
resolveWitnessSizes consolidating per-hash size resolution (rawdb +
header-existence DoS guard + SRC cache fallback)
tests/bor/helper.go:
- InitMinerWithPipelinedSRC (65) → 32 + newPipelineTestNode (17),
importValidatorKey (11)
- InitImporterWithPipelinedSRC (64) → 31 (same helpers)
Mutation coverage. Ran diffguard in diff-scoped mode (-base develop
-include-paths <module>) across every module pipelined-src touches and
filled the gaps it surfaced:
- core/state: adds core/state/statedb_pipeline_mutations_test.go with 41
targeted tests that kill 24 of 28 mutation survivors in pipelined-src
FlatDiff code (statedb.go lines 2031-2330, 2492-2499). The 4 remaining
are equivalent mutants — Finalise removes destructed addrs before the
guarded branches can fire (2114, 2163), a zero-length loop produces
the same output with or without the guard (2141), and an empty-slice
map entry is observationally equivalent to a missing entry (2150).
Covers CommitSnapshot and its capture helpers, ApplyFlatDiff +
applyFlatAccountOverlay, ApplyFlatDiffForCommit + applyFlatMutation,
NewWithFlatBase, TouchAllAddresses + touchAddressAndStorage +
mutatedStorageKeys, WasStorageSlotRead, and PropagateReadsTo — 14 of
15 functions at 100% line coverage (captureReadOnlyAccount at 90.9%).
- core/stateless: extends witness_test.go with 3 tests targeting
ValidateWitnessPreState's expectedBlock guard (ParentHash and Number
checks that defend against a malicious peer substituting a witness
for a different block / fork). Previous tests all passed nil for
expectedBlock, leaving the entire anti-forgery branch uncovered.
- eth/filters: adds TestResolveBlockNumForRangeCheck and
TestCheckBlockRangeLimit (16 subcases) to api_test.go covering the
RPC range-limit DoS guard at the unit level (sentinel resolution,
span-at-limit boundary, sum-vs-span distinction). Extends
TestInvalidGetRangeLogsRequest in filter_system_test.go to also
exercise GetBorBlockLogs with an inverted range — previously only
GetLogs was covered.
Per-module mutation scores after this coverage: miner 96%, consensus/bor
100% (41/41), core 100% (447/447), core/state 86% (24/28 equivalent),
core/stateless 100% (8/8), core/txpool 100% (12/12), tests/bor 100%
(43/43), triedb/pathdb 100% (20/20). eth at 53% — remaining survivors
are in auto-generated gen_config.go boilerplate (36), P2P dispatcher
cancel-channel plumbing awaitWitnessResponse goroutine cleanup
(3); documented as accepted gaps requiring complex mock infrastructure
for diminishing security return.
Remaining diffguard violations in miner and core are pre-existing
develop functions (commitTransactions, insertChainWithWitnesses,
newWorkLoop, NewBlockChain, ProcessBlock, writeBlockWithState, etc.)
that were over threshold on develop before pipelined-src. Their
pipelined-src deltas are now small (+1 to +42 lines) and out of scope
for this PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renames (reviewer nits):
- PostExecutionStateAt → PostExecState (BlockChain + txpool/legacypool/
blobpool interfaces + test mocks).
- ResetSpeculativeState → SetSpeculativeState, SpeculativeResetter →
SpeculativeSetter (the method overwrites, it doesn't revert).
Dedup between writeBlockAndSetHead and writeBlockAndSetHeadPipelined.
Both paths now share resolvePostWriteStatus(block, stateless) for
fork-choice + reorg (stateless flag preserves the errInvalidNewChain
escape for fast-forward sync), emitPostWriteEvents for the feed sends,
and writeBorStateSyncLogs for the pre-Madhugiri bor receipt. ~80 lines
of duplicated fork-choice + event logic removed; writeBlockAndSetHead
drops from ~70 to ~11 lines. Batch bodies intentionally not merged —
witness source (statedb.Witness() vs pre-encoded bytes) and trie-commit
timing genuinely differ.
Miner coinbase unification: extracted resolveCoinbase(blockNumber,
fallback) used by makeHeader (fallback=genParams.coinbase) and the
speculative header builders (fallback=etherbase()). Divergence between
the speculative and real header would cause a state root mismatch, so
single-sourcing this is security-meaningful. Rest of buildInitialSpecHeader
kept separate from makeHeader (placeholder parent, deterministic bor
period timestamp, static GasCeil, no engine.Prepare); comment documents
why unifying further would hurt readability.
Pipelined import correctness fixes (core/blockchain.go):
1. persistPipelinedImport now runs the Heimdall milestone/checkpoint
ValidateReorg guard before writeBlockAndSetHeadPipelined — mirrors
the non-pipelined path. Without it, a milestone whitelisted during
block execution could be bypassed.
2. verifyImportSRCRoot wraps its writeHeadBlock revert in chainmu
TryLock/Unlock. The call ran in the auto-collection goroutine
without the mutex, racing any concurrent InsertChain on head state.
Skips + warns if chainmu is closed (shutdown).
3. flushPendingImportSRC error in insertChain's ProcessBlock error
path no longer discarded with `_`; logged like the other two call
sites.
Linter: dropped two `tc := tc` loop-var copies in eth/filters/api_test.go
(copyloopvar, redundant since Go 1.22).
|
Thanks — this was a genuinely valuable review. I verified all seven findings independently against the source (three separate deep-dives plus a direct read of the mutex semantics for #1), and every one is real. All are fixed in 6463a59, along with the three test gaps and the side observations. Three places where my dig went further than the write-up, and each one changes the fix — worth reading before the re-review: #2's obvious fix would have been a new bugHoisting the destruct check above the overlay probe is exactly what I was about to do, and it's wrong: The fix tracks this block's own destructs in a separate Also confirmed your EIP-6780 caveat holds structurally, not just probabilistically: a #6 fails in both directions, not oneYou flagged SRC(N) starving on the swapped backend. The main thread's own execution of N+1 breaks too: in FlatDiff mode it executes against Fixed by computing #5: one sub-claim doesn't hold
What changed#1 / #3 / #5 — one rewrite, since they share a cause. The collector no longer touches One extra trigger for #3 you didn't list: #4 — clamped at the source ( #7 — treated as the most urgent since it ships regardless of the flag. Two changes: a lookup-index rejection is authoritative again ( Side items — a locally unavailable parent state no longer writes the block to the bad-block DB ( Tests, and I checked they actually failYour first suggested test was the highest-value one — it caught #1, #4 and #5 exactly as you predicted.
Two of your new coverage tests needed updatingHeads-up since they're yours (11636e8) — both encoded the old behavior and correctly failed against the fixes:
Claim corrections and CIBoth accepted. The description's "flushed on reorg, gap, or error" and the mismatch-recovery claim were wrong as written; I'll rewrite those and refresh the deviations section — you're right that it's stale, only Quality metrics (diffguard) is red now, Full sweep green on 6463a59: Given the size of what this surfaced in the failure paths, I don't think the earlier "ready modulo soak" framing holds — I'd like your re-review of the recovery path specifically before this goes near the soak. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 82 out of 83 changed files in this pull request and generated no new comments.
Suppressed comments (2)
core/stateless/witness.go:76
ValidateWitnessPreStatecomputescontextHeader.Number.Uint64()-1without guarding againstNumber == 0(genesis) orNumber == nil. For a genesis-context witness this underflows toMaxUint64and produces a misleading lookup/error. Add an explicit check and return a clear error before subtracting.
This issue also appears on line 115 of the same file.
core/stateless/witness.go:123
NewWitnessnow callstypes.CopyHeader(context)(which dereferencescontext) without guarding againstcontext == nil. This is a behavior change: previously a nil context could slip through and be handled later, but now it will panic immediately. Also,context.Number.Uint64()-1underflows for genesis headers. Add explicit nil/genesis checks before accessingcontext/subtracting.
Follow-up: V2 witness completeness — this branch predates #2333Separate topic from the failure-path findings, and it only affects the witness-producing configuration. #2333 ("produce complete witnesses under BlockSTM v2") merged into To be clear about what is and isn't already handled here, because there are two layers to it: Already covered on this branch. Not covered on this branch. The reason I'm raising it against this PR rather than just noting the merge: the witness validation here structurally cannot see this failure.
So "witness proof-node-set parity + stateless replay across all configurations" doesn't currently establish V2 witness completeness for the pipelined path. Worth softening that line in the description until it's re-established on a merged branch. Merge overlapThe two changes collide in the witness path specifically:
Two things I'd check deliberately rather than trust the merge resolution on:
Cheapest way to settle it#2333 already ships the oracle: One sequencing note: per #2333's rollout notes, v2.9.2 ships a guard forcing the serial processor for witness-recording blocks. I don't see that guard in |
…tion The develop merge brings #2333's witness completeness fix into the pipelined branch, and this commit resolves the collision between that work and the branch's base-read error tracking. SafeBase read methods now return errors instead of promoting every pooled-copy failure into one block-global error: a speculative incarnation chasing stale values may legitimately read outside the canonical state (on witness-backed replay such state simply doesn't exist) and is then invalidated, so a block-global error over-triggers. Each ParallelStateDB records its own BaseReadErr, cleared on Reset. The fatal check runs inside the settle callback, the only point where a pdb is provably the settled incarnation. Scanning the executor's states array after the fact reads recycled pool objects: settlement returns each pdb to the pool, a later tx's Reset reuses it, and stale slots alias the mutated object — a speculative error from a later tx then surfaces under earlier indices (observed as ten aliased slots reporting one invalidated incarnation's miss), while a genuinely settled error can vanish before the scan. The witness regeneration oracles are anchored against each block's real mainnet roots; previously the round trip compared only against its own stage-1 replay, so a fixture whose replay silently diverged from mainnet would pass against itself. The settle-time gate then exposed three fixture blocks whose witness/code archive lacks an EIP-7702 authority's pre-state code blob (validateAuthorization reads the code to parse the delegation; the fixtures were captured by a client whose V2 base reads silently nil-served the miss). Those are pinned by exact hash as known-incomplete fixtures. Serial replay of them still anchors only because a missing blob reads as empty code, which accepts the authorization just like the real delegation designator does. Also constructs the detached-prefetcher warm-snapshot test through the real constructor: a hand-rolled triePrefetcher literal has nil meters, and report() panics when another test's global metrics.Enable() wins the race. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@cffls Thanks — this follow-up was extremely productive. All three parts are now addressed on the branch ( #2333 merged, with one deliberate collision resolution
The collision: this branch tracks base-read failures in V2 workers (develop's Your point 2 (SRC must extend, not rebuild) — settled empirically
Bug 1 (ours): the settled-incarnation check was reading recycled pdbsOne fixture block (83014065) kept failing both AllBlocks oracles with a The abort came from our gate: it scanned the executor's Bug 2 (yours, I think): witnesses are missing EIP-7702 authority pre-state codeWith the gate fixed, three other fixture blocks (83014074, 83014100, 83020871) started failing deterministically with The uncomfortable part: serial stateless replay of these witnesses anchors to the real mainnet roots anyway, purely by convergent divergence — the authority's real code is a delegation designator (authorization accepted) and a missing blob reads as empty code (also accepted). The two paths happen to produce identical receipts and roots. The day an authority holds real contract code, that convergence breaks: a full node rejects the authorization ( For this PR I've pinned the three blocks as Oracle hardening that fell out of thisBoth round trips now anchor the stage-1 replay against each block's real roots from the fixture header. Previously the round trip was self-consistent only — a fixture whose replay silently diverged from mainnet (zero-ish reads swallowed symmetrically on both sides) would pass against itself, which is exactly how the 7702 gap stayed invisible. With the anchor: serial 241/241, pipelined 241/241. On your point 1 (
|
ValidateWitnessPreState trusted the transport decoder to guarantee a non-nil, non-genesis block number on the witness context header. RLP decoding does guarantee non-nil today, but this function is the boundary check for peer-supplied witnesses — it shouldn't inherit its input invariants from whichever decoder happened to run. A nil number would panic on Uint64(), and a genesis number would underflow the parent lookup to MaxUint64 (harmless miss, but an unrelated probe). Both now return validation errors, and the parent number is computed once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 87 out of 88 changed files in this pull request and generated no new comments.
Suppressed comments (1)
core/v2_blockstm_test.go:275
- The test filters incomplete embedded witness fixtures by string-matching
err.Error()for "v2: base read: missing trie node". This is brittle (error text/wrapping can change) and can misclassify failures. Prefer a typed check (e.g.,errors.As(err, *trie.MissingNodeError)against the wrapped cause) so the skip-vs-fail decision is stable across error formatting changes.
One remaining gap from the re-review: rollback is still collect-path-onlyRe-checked on
So on those paths the rejected block keeps its canonical hash and tx-lookup entries, Partly self-healing — a genuine reorg rewrites canonical hashes through the normal reorg machinery, and a restart rewinds a head whose state is missing — but "self-heals on restart" isn't the property you want backing the mechanism that exists to contain an unverified head. Suggested shape: call Three smaller items from the same pass, all optional:
Follow-up suggestion: enable delayed SRC in the kurtosis e2e legsNot for this PR necessarily, but worth tracking as a follow-up item: because That's an awkward coverage shape for this feature specifically, because the interactions I'd least trust to unit tests are exactly the ones kurtosis covers well:
Wiring-wise the args file is external — Doing this before the 24h soak would also mean the soak isn't the first time the pipeline meets a multi-node network. |
recoverFailedPipelinedImport was reachable only from the collect path; the reorg/gap, ProcessBlock-error and witness-fed flushes cleared the pending entry and returned the error without undoing the published head, leaving the rejected block canonical while the import continued around it. flushPendingImportSRC now takes a rollback flag: the three callers that hold chainmu pass true; shutdown passes false (it cannot take chainmu, and the startup rewind covers it). The rollback also emits RemovedLogsEvent for the rejected block's logs (collected before its indexes are deleted), matching the retraction a reorg sends, so filter and subscription clients drop them. insertChainWithWitnesses returns -1 instead of clamping to 0 when a cross-batch SRC failure has no previous in-batch index: both consumers treat out-of-band indices as "failure outside this batch" and only log, whereas the clamp blamed (and bad-block reported) the batch's first block for a previous batch's failure. TestFlatDiffOverlay_ParentDestructKeepsOverlayStorage pins why same- block and parent-block destructs live in separate sets: a parent-block destruct seeded by the FlatDiff replay paths must not shadow overlay storage written after the resurrection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by running the kurtosis e2e topology with the pipeline enabled on validators: the network deterministically split at the first sprint boundary. commitWork opened sealing state through StateAtWithReaders, which serves the FlatDiff overlay whenever the parent is the latest pipelined import — an overlay statedb is rooted at the grandparent with the parent's writes installed as unjournaled read-only objects, so the root sealed into the header omits every parent write the new block does not rewrite (on the devnet, the parent's EIP-2935 block-hash slot). Every importer — the pipelined SRC and stock bor alike — recomputes the correct root and rejects the block. SealingStateAt / SealingStateAtWithReaders wait (bounded) for the parent's pending SRC to commit and open the committed trie, never the overlay; commitWork and resolveStateFor use them, and commitWork re-checks the head after the wait since a failed SRC rolls it back. The overlay-serving StateAt/StateAtWithReaders remain for RPC readers and pending-block serving, where no header root is derived from them. recoverFailedPipelinedImport now clears canonical hashes and tx lookups for the rejected block's published descendants as well: SRC verification trails the insert, so a batch can publish blocks above the rejected one before its failure surfaces, and rolling back only the failed block left number-based lookups resolving blocks above the head. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@cffls All four items from the re-review are addressed in R1 — rollback on every
|
Local kurtosis run of both e2e legs with the pipeline enabled — found and fixed a producer-side consensus bugFollowing up on the CI-coverage discussion: we replicated both kurtosis e2e legs locally (kurtosis-pos The bug (fixed in
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 87 out of 88 changed files in this pull request and generated no new comments.
Suppressed comments (2)
core/state/v2_executor_differential_test.go:93
- BaseNonce ignores the error returned by SafeBase.GetNonce. If a base-state read fails (e.g., missing trie node in a fixture), this helper will silently treat the nonce as 0 and the differential harness may proceed with incorrect inputs, masking regressions.
Since this is test-only code, it’s better to fail fast on any SafeBase read error (panic is fine here) so the harness can’t pass on zero-ish substituted values.
core/stateless/witness_test.go:223
- This assertion slices err.Error() without checking its length. If ValidateWitnessPreState returns a different/shorter error string, the test will panic with an out-of-range slice instead of failing with a helpful message.
Guard the length before slicing so the test fails cleanly.
Pipelined SRC produces witnesses that fail stateless replay (live dev-node A/B on
|
| configuration | blocks | pass | failing | missing trie nodes |
|---|---|---|---|---|
enable-import-src = false, V2 enforce |
100 | 100 | 0 | none (dbErr: <nil>) |
enable-import-src = true, V2 enforce (cold) |
99 (+1 state-sync skip) | 0 | 99 | every block |
enable-import-src = true, V2 enforce (warm, 80 min uptime) |
150 | 0 | 150 | every block |
enable-import-src = true, V2 off (serial) |
81 | 12 | 69 | most blocks |
Failures are both account-trie (getStateObject (addr) error: missing trie node ... (path 0b0102)) and storage-trie (missing trie node ... (owner 36ecd3bd...) (path 09)). The ~50% gas shortfall in the raw output is downstream: once a read fails, the statedb returns zero-ish values and execution diverges.
Witness density rules out "collection dropped nodes" — pipelined witnesses are larger, not smaller:
| configuration | nodes/Mgas | KB/Mgas |
|---|---|---|
| pipeline off + V2 | 249.9 | 93.1 |
| pipeline on + V2 | 279.1 | 104.7 |
| pipeline on + serial | 257.9 | 96.8 |
More nodes, wrong nodes — the signature of a generation mismatch rather than a coverage shortfall.
Diagnosis
The root offset. Importing block N, execution runs against the last committed root — root_{N-2} — plus the FlatDiff overlay of N-1, because SRC(N-1) hasn't been collected yet. The overlay makes the values correct, but trie nodes fetched from the reader are of the root_{N-2} generation. The published witness for block N must contain nodes rooted at root_{N-1}. So for any subtree modified in block N-1, the exec-side witness carries the stale generation and lacks the correct one. That matches the density numbers exactly: stale nodes ride along as dead weight while needed ones are absent.
Single point of failure. preloadFlatDiffReads (core/blockchain.go:5383) is the only thing that makes SRC re-read at the correct root to collect valid proof nodes, and it is driven entirely off flatDiff.ReadSet / ReadStorage / NonExistentReads. Witness completeness therefore reduces to "is the FlatDiff read-set a complete record of everything execution read?"
One confirmed contributor — V2 worker reads. Those sets are built exclusively from the final statedb: captureReadOnlyAccount, captureObjectStorage and captureNonExistentRead all iterate s.stateObjects and each object's originStorage. Under BlockSTM v2, worker reads live on ParallelStateDB instances backed by SafeBase pool copies that are discarded after settle and never reach finalDB.stateObjects. This is the same defect #2333 identified for the witness and fixed with a shared-reader drain (CollectStateWitness) — the FlatDiff read-set has the same defect and no equivalent drain. Disabling V2 moved the pass rate from 0/99 to 12/81, confirming it as a real contributor.
Not the whole story. 69/81 still fail in serial mode, where the read-set should be complete, so there is a second gap in read-set capture I did not pin down. Candidates I traced but could not eliminate by inspection: slots of N-1-mutated accounts that fall through the overlay to the stale storage trie, and objects no longer in s.stateObjects by the time CommitSnapshot runs.
Why the round-trip test passes
TestV2WitnessRegenerationPipelinedSRC / PipelinedSRCAllBlocks do drive the real split via witnessRegenPipelinedRoundTrip — the flag isn't the issue. They pass because the harness is single-block: execution and SRC both open at pb.witness.Root(). With one block there is no N-2, the offset is zero, the exec witness is already the right generation, and the read-set gap is fully masked. That's how 241/241 green coexists with 0/99 on a live node.
Reproducing it in go test
The minimum shape needs three block-heights: state committed at root_{N-2}; execute N-1 and CommitSnapshot without committing; execute N with the reader bound to root_{N-2} plus the FlatDiff_{N-1} overlay; run SRC(N-1); then round-trip block N's collected witness.
No new fixture capture needed — a consecutive pair supplies both roots, since witness_{N-1}.Root() is root_{N-2} and witness_N.Root() is root_{N-1}. The LFS set in core/blockstm/testdata isn't contiguous overall but contains 13 runs of ≥3 consecutive blocks:
| run | blocks |
|---|---|
| 83017816–83017860 | 45 |
| 83014081–83014113 | 33 |
| 83017760–83017792 | 33 |
| 83020826–83020843 | 18 |
A chained variant of witnessRegenPipelinedRoundTrip over one of those runs should fail in-tree. Two caveats: build the memdb from the union of the consecutive witnesses so both root generations are available, and anchor each block against its real state root from the fixture header (the anchoring already added here) rather than self-consistency — otherwise the same convergent-divergence blind spot that hid the 7702 gap could hide this too. Also worth avoiding the 83020871–83020898 run, since 83020871 is one of the knownIncompleteWitnessFixtures and would tangle the two issues.
Suggested fix direction
Deriving witness completeness from a reconstructed read-set is the fragile part — it depends on which statedb happened to observe each read. The robust fix is the same shape as #2333's: drain the shared reader's caches into the FlatDiff read-set (the reader already knows every key read, worker or not) instead of rebuilding it from finalDB.stateObjects. That closes the confirmed V2 contributor and plausibly the residual one, since it removes the dependency on read attribution entirely.
Happy to hand over the replay tooling if useful. Note this does contradict the "witness proof-node-set parity + stateless replay across all configurations" claim in the description, and it's the concrete failure mode the earlier "SRC must extend, not rebuild" concern was circling — extending turns out to be necessary but not sufficient, because the extended nodes are the wrong generation.
…ords
Under pipelined import the witness for block N must carry root_{N-1}
proof nodes, but execution reads at root_{N-2} plus the parent FlatDiff
overlay — the SRC re-read at the parent root is the only source of
current-generation nodes, and it is driven entirely by the FlatDiff
read surface. That surface was rebuilt from finalDB.stateObjects, which
misses BlockSTM worker reads (pool copies are discarded after settle),
overlay-served reads (they never reach the reader), and reader-walked
read-only slots of accounts the block also mutates (IntermediateRoot
harvests only obj.trie write paths for mutated objects and its
read-only loop skips them).
CommitSnapshot now drains two shared, attribution-free read records
when a witness is being produced: the reader cache (every key resolved
through the shared reader, from any worker or statedb) and an
overlay-read tracker recorded inside the FlatDiff probes. The SRC
statedb additionally drains its reader tracers via CollectStateWitness
— the same call the execution side gained in #2333 — and the preload
iterates ReadStorage directly so slots without a ReadSet/Accounts entry
still get their paths walked.
TestV2WitnessRegenerationPipelinedSRCChained pins the failure shape: a
chained round trip over all 222 consecutive fixture pairs, executing
block N over the parent FlatDiff overlay with readers at root_{N-2} and
anchoring both replays against the real mainnet roots. Before the fix
every sampled pair failed stateless replay; the sweep is green now, as
are both AllBlocks oracles and a race pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@cffls Confirmed on all counts — reproduced in-tree at exactly the shape you suggested, root-caused, and fixed in The repro
Your read on why the single-block oracles passed was also exactly right — with one block there is no N-2, so they are structurally blind to the offset. They stay in the suite as same-generation regression checks; the chained sweep is now the one that carries the load. Three gaps, not two
Verification so far
One expected side effect worth noting for your density metric: the drained read-set includes speculative prefetcher reads (the cache is shared), so witnesses get somewhat larger rather than smaller — a superset costs size, never correctness. If the bloat is measurable we can role-tag the cache entries in a follow-up. The PR description's witness-completeness claim has been rewritten accordingly. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 87 out of 88 changed files in this pull request and generated no new comments.
Suppressed comments (1)
core/stateless/witness_test.go:223
- This assertion can panic if the returned error string is shorter than expectedError (slicing err.Error() without a length guard). Even if today’s error messages are longer, keeping the test panic-free makes failures easier to diagnose.
|
Follow-up: both in-progress verification items from the previous comment are done, and the live A/B is decisive. Live dev-node witness replay A/B (mainnet, syncing at tip)Same methodology as yours — replay every produced witness through the real serial processor with a stub engine, anchored against the canonical block roots (gas + receipt root + state root). Same node, same config, only the binary differs; the pre-fix witnesses were still on disk, so the control and the fixed run share everything else.
The 0/200 control reproduces your 0/99 and validates the harness; the 200/200 confirms the fix end-to-end on live mainnet blocks. On density: the fixed build measures 294.0 nodes/Mgas, 110.0 KB/Mgas over the passing range (vs your 279.1 / 104.7 for the broken pipelined variant and 249.9 / 93.1 for pipeline-off — different block samples, so indicative only). That's the expected superset cost of the attribution-free drain (~+5% nodes over the broken variant, ~+18% over pipeline-off). If that overhead matters for witness-serving nodes we can role-tag the reader-cache entries in a follow-up and drop the speculative-prefetcher share. Stateless kurtosis leg, rerun on the fixed build459 blocks (through Rio at 128), all nine nodes — 3 witness-producing validators, 2 stateless-sync validators, the bor RPC witness-flag matrix, and erigon — at identical head, The replay harness from the A/B is a ~100-line self-contained test (opens chaindata + witness filestore read-only, replays a block range); happy to compare notes against your tooling or fold them together into the standing check discussed for CI. |
Description
Overlaps state-root computation (SRC) of block N with transaction execution of block N+1 during block import. Execution of N+1 opens state at the last committed trie root plus an in-memory
FlatDiffoverlay of block N's mutations, while a background SRC goroutine commits block N and computes its root. Disabled by default — enabled with[pipeline] enable-import-src = true.Built on top of the delayed SRC PoC: instead of just deferring SRC, it pipelines SRC with the next block's work.
Measured impact
Mainnet full node (n2d-standard-16, path scheme, BlockSTM enforce), ~64k-block catch-up windows, block-weight and traffic-character controlled across adjacent weekday ranges:
producewitnessesproducewitnessesPer-block overhead beyond raw execution dropped from ~45–50ms to ~18ms. Zero
root_mismatch, pipeline fallbacks, or execution errors across the benchmark campaign (~500k blocks imported under the pipeline).How it works — import
When importing block N (pipeline active):
ValidateStateCheap(gas, bloom, receipt root — noIntermediateRoot)FlatDiffviaCommitSnapshot(~1ms, no trie hashing)StateAt/RPC reads (eth_call,eth_estimateGas, pending reads) are correct during the pipeline windowTwo supporting optimizations ship with the pipeline:
triedb/pathdb/lookup_nodes.go): a reference-counted(owner, path, hash) → blobmap over live diff layers.reader.Noderesolves in one probe instead of walking up to 128 diff layers per read (the walk measured ~44% of process CPU during catch-up). The hash is part of the key, so a hit is correct by construction; a definitive miss reads the disk layer directly. This index is always on (not gated by the pipeline flag) — it is a read-path cache whose misses fall through to the existing walk, adding ~250MB steady-state at 128 diff layers.core/state/warm_snapshot.go,pipeline.warm-snapshot, default on): when witnesses are produced, the execution-side trie prefetcher is detached and handed to SRC, which builds an immutable hash-verified snapshot of the loaded trie nodes and consults it before pathdb. No effect when witnesses are off.Review-sensitive properties
ValidateStateCheapgates insertion; the full root check happens asynchronously in the SRC goroutine. A mismatch fireschain/imports/pipelined/root_mismatch(a hard alarm that must stay 0) and errors the pipeline. Sync compensates:hasPendingPipelinedHeadStatekeeps the node in full-sync while a head-state commit is in flight.WitnessReadyEventnow push-announces witness availability to stateless peers (replacing a 10s poll);GetWitnessserving waits for in-flight SRC (bounded, with a header-existence DoS guard) and reads uncached so peer traffic can't evict import-critical cache entries.root_{N-2}) plus the parent FlatDiff overlay, so the correct-generation (root_{N-1}) proof nodes can only come from the SRC's re-read at the parent root, driven by that read surface. The review found (comment thread) that the surface was originally rebuilt fromfinalDB.stateObjectsand undercounted three read classes: BlockSTM worker reads, overlay-served reads, and reader-walked read-only slots of accounts the block also mutates. Fixed in12bd14abe:CommitSnapshotdrains the shared reader cache and an overlay-read tracker (both attribution-free, complete by construction), and the SRC statedb drains its reader tracers viaCollectStateWitness(the same call core, core/state: produce complete witnesses under BlockSTM v2 #2333 added on the execution side). Pinned byTestV2WitnessRegenerationPipelinedSRCChained, a chained round trip over all 222 consecutive mainnet fixture pairs that reproduces the steady-state generation offset (block N executed over the parent's FlatDiff overlay) and stateless-replays the SRC-completed witness against real mainnet roots — it failed on every sampled pair before the fix. The single-block oracles (TestV2WitnessRegenerationAllBlocks,TestV2WitnessRegenerationPipelinedSRCAllBlocks, 241 blocks) remain as same-generation regression checks; they are structurally blind to the cross-block offset, which is why they alone were insufficient. Caveat: three fixture blocks carry a known pre-existing witness gap (EIP-7702 authority pre-state code missing from the fixture archive — see the PR discussion) and are pinned by exact hash; a live-node witness-replay A/B on the fixed build is in progress to confirm end-to-end, and prewalk volume / settle-drain numbers will be re-measured during the soak on the fixed build.PropagateReadsToincheckAndCommitSpancaptures the validator-contract proof nodes read via a copied statedb; EIP-2935 accounts are touched into the read set.bc.wg. Pending SRC is flushed on reorg, gap, shutdown, and a witness-fed block; on a collect failure (SRC error or root mismatch) the collecting thread runs a full rollback — clears the pending entry, drops the FlatDiff overlay, deletes the rejected block's canonical hash and tx lookups, moves the head back, dereferences the divergent root, and emits a correctiveChainHeadEvent.SetHead/setHeadBeyondRootrewinds do not flush; they rewind to committed state, which supersedes any in-flight SRC.BlockChainVersionunchanged); nothing forces a resync.Production-side (miner) pipelining: landed but disabled
miner/pipeline.gocontains the speculative-sealing counterpart (FlatDiff extraction afterFinalizeForPipeline, background SRC, speculative N+1 build, async chain write). It is hard-disabled:isPipelineEligiblereturnsfalseunconditionally and no config exposes it (theworker/pipeline/enabledgauge is always 0). Pre-Rio seal-recovery interaction makes speculativePreparefail; re-enablement is future work and the gating logic is preserved in comments. Reviewers can treat the miner path as dormant code with test coverage.Config
Notable negative results baked into the design (so nobody re-litigates them): the exec-side trie prefetcher and the speculative block prefetcher are load-bearing via shared-cache warming — disabling either regresses 25–75% — so neither is configurable (
pipeline.exec-prefetchexisted briefly on the feature branch and was removed).Executed tests
-race): pipelined vs sequential roots/hashes, witness regeneration round trips (241 mainnet fixture blocks, mainnet-root-anchored, inline + pipelined-SRC completion) plus proof-node-set parity suites, FlatDiff mutation/overlay suites, self-destruct integration, pathdb node-index unit tests (fork refcounting, deletion markers, definitive-miss semantics),tests/borintegration.core/pipelined_window_test.go, SRC goroutine held open via test hook) pins the overlay/trie semantics of the "head advanced, root not yet committed" interval; an end-to-end battery (tests/bor/pipelined_rpc_test.go) drives 12 read methods against a live-syncing importer at pinned heights andlatest, checks sync-time vs settled response identity, and verifies full per-height parity vs a non-pipelined BP including cryptographic account-proof verification. Handlers that need committed trie nodes (eth_getProof,debug_storageRangeAt) gate onWaitForPipelinedStateCommitinstead of erroring transiently inside the window.Quality gates — declared deviations
One CI gate fails for structural reasons; it is intentional and listed here rather than suppressed:
insertChainWithWitnesses,IntermediateRoot,getStateObject, andupdateTrieexceed thresholds. All four are legacy hot-path giants that this PR extends in place; refactoring them is high-risk consensus-path churn that belongs in a dedicated cleanup, not a feature PR. New-code violations were extracted below thresholds (applyFlatMutationFast,runSRCCompute,FinalizeForPipeline, witness-collection loops deduped intoaddObjectWitness).nodeWalkis a rename of pre-existing upstream logic. The reportedcore -> coredependency cycle is a tool artifact (self-cycle).codecov/patchand SonarCloud were listed here previously and are no longer red — patch coverage now passes after the added test coverage, and SonarCloud's duplication gate passes after the production-code dedup (addObjectWitness,commitSprintWorkreuse). Quality metrics (diffguard) is the only remaining red check.Rollout notes
Backwards-compatible; off by default; no coordinated upgrade required. Not consensus-affecting: the pipeline computes the identical root on the identical commit path, one block later in wall-clock, and aborts on mismatch. Operator-facing changes when enabled: witness-ready latency trails the block by one SRC cycle (~210ms end-to-end vs ~143ms inline). Related: #2312 implements an alternative trie-node index on develop; this PR lands first and #2312 rebases.