execution: parallel-only ExecV3 + ephemeral single-block replay harness - #22733
Conversation
…dependent core Make ExecV3 the parallel executor only, dropping its StageState/Unwinder coupling. Stage coordination (SeekCommitment, restoreTxNum, aggregator concurrency preset, progress Update) and the bad-block unwind are hoisted into SpawnExecuteBlocksStage; the legacy serial path is not removed but moves to execV3Serial, driven by the stage process. The unwind routing is extracted to unwindOnExecError and unit-tested (TestUnwindOnExecError). Adds the seams an ephemeral single-block replay needs: - a blockSource for the executor's block input (default DB-backed), - execctx.WithMemBatch so SharedDomains can be backed by a supplied in-memory batch instead of tx.Debug().NewMemBatch, - DISCARD_COMMITMENT nils the commit stream so the parallel path runs exec-only (the commitment calculator idles and the apply-loop exit is driven by exec, not the trie root). Also guards a nil notifications deref in the parallel apply loop.
…arness Replay a captured block through the real parallel ExecV3 with no MDBX behind the state: SharedDomains is backed by a witness mem batch holding the block's flat pre-state (via execctx.WithMemBatch), blocks/headers come from an in-memory FullBlockReader, and commitment is disabled. This lets discrete, repeatable single-block tests run on the parallel execution path — e.g. outlier blocks captured from tip processing. A fixture captures the block's inputs (read-set) and its authoritative canonical outputs (post-state read from the committed chain at end-of-block, never re-derived by an executor). The replay's post-state is checked against those outputs (data, not the trie root; block consistency — receipts/gas/bloom — is already validated by the executor), so profiling runs on verified-correct execution. Adds `integration capture_block --block=N` to produce fixtures from a node.
Move per-run setup (witness SharedDomains) and the post-state check outside the timed region (StopTimer/StartTimer) so BenchmarkEphemeralParallelReplay measures the ExecV3 call alone, not fixture setup or output verification.
There was a problem hiding this comment.
Pull request overview
This PR refactors Erigon’s execution stage so ExecV3 becomes a stage-independent, parallel-only execution core, and adds an ephemeral single-block replay harness (with capture tooling) to exercise the parallel path offline against captured real-chain blocks.
Changes:
- Hoists stage coordination (range resolution, progress update, bad-block unwind routing) out of
ExecV3intoSpawnExecuteBlocksStage, while keeping the legacy serial path asexecV3Serial. - Introduces a block replay fixture format + capture/replay APIs and tests, including a DB-free
FullBlockReaderand authoritative post-state output checking. - Adds an integration command (
capture_block) to generate fixtures from a running node/datadir.
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| execution/tests/blockreplay/witnessmembatch.go | Adds a witness-backed TemporalMemBatch seam for SharedDomains reads/writes during replay. |
| execution/tests/blockreplay/witness_domains.go | Builds witness-backed SharedDomains using the injected mem-batch seam. |
| execution/tests/blockreplay/seed.go | Seeds fixture pre-state into real domains using the production writer. |
| execution/tests/blockreplay/seed_test.go | Verifies seeded pre-state reads back through SharedDomains correctly. |
| execution/tests/blockreplay/rlp.go | RLP encode/decode helpers for blocks/headers in fixtures. |
| execution/tests/blockreplay/replay_bench_test.go | Adds replay test + benchmark harness for a mainnet fixture. |
| execution/tests/blockreplay/outputs.go | Defines authoritative outputs capture, collection, and diffing for replay verification. |
| execution/tests/blockreplay/outputs_test.go | Pins that fixtures contain authoritative captured outputs (incl. coinbase credit). |
| execution/tests/blockreplay/memreader.go | Implements a DB-free FullBlockReader backed by fixture data. |
| execution/tests/blockreplay/harness.go | Implements fixture capture (from canonical history) and DB-free replay execution. |
| execution/tests/blockreplay/decode.go | Fixture decode helpers (block, parent header, senders list). |
| execution/tests/blockreplay/blockreplay.go | Defines fixture structures and in-mem/recording state readers. |
| execution/tests/blockreplay/blockreplay_test.go | End-to-end capture→save→load→replay round-trip test. |
| execution/stagedsync/stage_execute.go | Moves range resolution/progress update/unwind routing to the stage boundary; calls new ExecV3 API. |
| execution/stagedsync/stage_execute_unwind_routing_test.go | Adds test coverage for the new unwind routing logic. |
| execution/stagedsync/exec3.go | Refactors ExecV3 into a parallel-only core + introduces execRange/execV3Outcome; adds execV3Finalize. |
| execution/stagedsync/exec3_parallel.go | Updates parallel executor to report implicated bad block info and supports exec-only mode (discard commitment). |
| execution/stagedsync/exec3_ephemeral_test.go | Adds benchmark that replays a captured block via parallel ExecV3 using witness-backed domains. |
| execution/stagedsync/exec3_blocksource.go | Introduces blockSource abstraction to drive execution from DB or an ephemeral source. |
| db/state/execctx/options.go | Adds WithMemBatch option to inject a custom in-memory batch into SharedDomains. |
| db/state/execctx/domain_shared.go | Wires WithMemBatch into NewSharedDomains mem-batch construction. |
| cmd/integration/commands/stages.go | Adds capture_block integration command to emit replay fixtures from a node/datadir. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ) (*Fixture, error) { | ||
| hash, ok, err := blockReader.CanonicalHash(ctx, tx, blockNum) |
There was a problem hiding this comment.
Addressed — Capture now returns an explicit error for block 0 (harness.go: if blockNum == 0 { return … "cannot capture genesis block (0): it has no parent" }) before the blockNum-1 parent read, so no MaxUint64 underflow.
| return &memBlockReader{ | ||
| block: block, | ||
| senders: fx.SendersList(), | ||
| num: block.NumberU64(), | ||
| parent: parent, | ||
| parentN: block.NumberU64() - 1, | ||
| ancestors: ancestors, | ||
| }, nil |
There was a problem hiding this comment.
Addressed — NewMemBlockReader returns an error for block 0 (memreader.go: if block.NumberU64() == 0 { return nil, … "cannot replay genesis block (0)" }) before computing parentN, so the NumberU64()-1 underflow is unreachable.
| if g.Nonce != w.Nonce || g.Balance != w.Balance || g.CodeHash != w.CodeHash { | ||
| diffs = append(diffs, fmt.Sprintf("account %x: got {nonce=%d bal=%x} want {nonce=%d bal=%x}", a, g.Nonce, g.Balance, w.Nonce, w.Balance)) | ||
| } |
There was a problem hiding this comment.
Addressed — Outputs.Diff now prints codehash in the account-mismatch message (got {… codehash=%x …} want {… codehash=%x …}), so a codehash-only divergence is no longer masked.
| // restoreTxNum must run before pe.run() so that doms.SetTxNum() completes | ||
| // before any goroutine reads txNum (via AsGetter/GetLatest). | ||
| restoredTxNum, _, _, _, err := restoreTxNum(ctx, &pe.cfg, rwTx, inputTxNum, maxBlockNum) | ||
| if err != nil { | ||
| return nil, rwTx, err | ||
| // before any goroutine reads txNum (via AsGetter/GetLatest). With an injected | ||
| // block source (ephemeral replay) the caller owns range resolution and there | ||
| // is no TxNums index to consult, so the passed-in inputTxNum is used as-is. | ||
| restoredTxNum := inputTxNum |
There was a problem hiding this comment.
Addressed — the executor no longer calls restoreTxNum when blockSrc == nil; range resolution (SeekCommitment + restoreTxNum) is owned by the caller and consumed by the executor (see the contract note in exec3_parallel.go). No second TxNums/index DB pass.
yperbasis
left a comment
There was a problem hiding this comment.
Requesting changes for one production unwind bug and several replay-correctness gaps. Existing focused package tests and a one-iteration replay benchmark pass, but targeted repros confirm the unwind, witness prefix/deletion, and output-oracle defects described inline.
| return handleIncorrectRootHashError(out.failedBlock, out.failedHash, out.applyTx, cfg, s, logger, u) | ||
| } | ||
|
|
||
| if out.lastHeader != nil { |
There was a problem hiding this comment.
[P1] Unwind the recorded failed block, not lastHeader. When a worker produces blockResult.Err, the apply loop records failedBlock/failedHash but does not update lastHeader; that still points to the previous successfully validated block. If block N fails, this branch therefore unwinds to N-2 and marks valid block N-1 as bad. If the first block in the batch fails, lastHeader is nil and no unwind is scheduled. Please use out.failedBlock-1 and out.failedHash for recorded failures, with a fallback only for failure paths that lack those fields. A focused repro expecting unwind point 19 currently gets 18.
There was a problem hiding this comment.
Addressed — the unwind now targets the implicated block, independent of lastHeader: execV3Outcome.failedBlock/failedHash drives UnwindTo(out.failedBlock-1, BadBlock(out.failedHash, execErr)) (and handleIncorrectRootHashError(out.failedBlock, out.failedHash, …) for the trie-root case) in stage_execute.go. Covered by stage_execute_unwind_routing_test.go, including the case where the batch’s first block fails so lastHeader is nil but failedBlock is recorded — the unwind is still scheduled to failedBlock-1.
| return nil | ||
| } | ||
|
|
||
| func (w *witnessMemBatch) GetLatest(domain kv.Domain, key []byte) ([]byte, kv.Step, bool) { |
There was a problem hiding this comment.
[P1] Prefix operations must include witness state. This wrapper overlays only GetLatest; SharedDomains.HasPrefix and IteratePrefix dispatch to the embedded delegate, which contains none of the values loaded into w.witness. As a result, HasStorage returns false for an account with captured storage, and account deletion via DomainDelPrefix leaves old witness slots readable later in the replay. I reproduced both cases. Please implement overlay-aware prefix operations or seed the delegate itself.
There was a problem hiding this comment.
Addressed — HasPrefix, IteratePrefix, and HasPrefixInRAM are now overlay-aware. prefixKeys merges the delegate’s keys with the witness pre-state and drops post-Seal-deleted / empty-value keys (witnessmembatch.go). So HasStorage returns true for an account with captured witness storage, and a slot cleared via DomainDelPrefix is no longer readable later in the replay. Both reproducers you cited now pass.
| // post-execution domains. This is the "Flush → outputs" read of the ephemeral | ||
| // model: after Execute, output state is received via the domains. It mirrors | ||
| // want's shape so the two can be compared directly. | ||
| func CollectOutputs(r state.StateReader, want *Outputs) (*Outputs, error) { |
There was a problem hiding this comment.
[P1] Validate the replay's actual write set. got is constructed exclusively by iterating keys in want, so an account, storage slot, or code entry written only by parallel execution is never read and cannot appear in Diff. Because commitment is disabled, receipts and gas do not detect a state-only extra write, allowing the benchmark to report verified-correct execution for incorrect state. Please compare the replay changeset/write-key set with the reference key set before comparing values.
There was a problem hiding this comment.
Addressed — the replay’s actual write-set is now validated. witnessMemBatch records every post-Seal domain write and writeSetDiff flags any account/storage/code key the replay wrote that is absent from want and whose post value diverges from the witness base (a state-only extra write commitment-off replay could not otherwise see). Wired into verify as writeSet.Diff(expected) (exec3_ephemeral_test.go), alongside the existing expected.Diff(got).
| return nil, nil, 0, false, nil | ||
| } | ||
| s.done = true | ||
| return s.block, nil, s.num, true, nil |
There was a problem hiding this comment.
[P2] Capture and feed the block access list. The DB-backed source reads the BAL sidecar and passes it into state.NewVersionMap, but this source always returns nil. Amsterdam or experimental-BAL fixtures are therefore replayed through the OCC/no-BAL scheduling path rather than the production BAL-driven path, so scheduler behavior and performance outliers cannot be reproduced faithfully. Please serialize the sidecar in the fixture and return it here.
There was a problem hiding this comment.
Addressed — the block source now captures and feeds the BAL: fx.BAL() populates singleBlockSource.bal, which next() returns (exec3_ephemeral_test.go), so Amsterdam / experimental-BAL fixtures get a non-nil BAL into NewVersionMap rather than always nil.
| diffs = append(diffs, fmt.Sprintf("account %x: missing (want present)", a)) | ||
| continue | ||
| } | ||
| if g.Nonce != w.Nonce || g.Balance != w.Balance || g.CodeHash != w.CodeHash { |
There was a problem hiding this comment.
[P2] Include account incarnation in the comparison. acctData captures Incarnation and the witness seeding path uses it, but this condition ignores it. A wrong incarnation after contract recreation or self-destruct therefore produces a clean diff even though subsequent storage/account behavior can diverge. Please compare and report Incarnation as well.
There was a problem hiding this comment.
Addressed — Outputs.Diff now compares Incarnation (in the account-mismatch condition and message), and CollectOutputs/toAcctData capture it. A wrong incarnation after contract recreation or self-destruct now surfaces as a diff.
| func loadFixture(tb testing.TB, block string) *blockreplay.Fixture { | ||
| tb.Helper() | ||
| p := filepath.Join("testdata", "block-"+block+".gob") | ||
| if _, err := os.Stat(p); err != nil { |
There was a problem hiding this comment.
[P2] Missing committed fixtures must fail, not skip. This fixture is checked into the PR and these tests are intended to validate it, so silently skipping when it disappears masks packaging or accidental-deletion regressions. It also falls outside the repository's allowed test-skip cases. Please let Load/require.NoError fail the test instead.
There was a problem hiding this comment.
Addressed — a missing committed fixture now fails (b.Fatal / require.NoError) instead of skipping, so packaging or accidental-deletion regressions surface rather than silently passing.
…y oracle, witness prefix ops, BAL capture - stage_execute: unwind out.failedBlock-1/failedHash (not lastHeader), with lastHeader fallback; unwind-routing tests updated + repro added - blockreplay: witness-aware HasPrefix/IteratePrefix/HasPrefixInRAM so prefix scans (HasStorage, DomainDelPrefix) see witness pre-state (+TestWitnessPrefixOps) - blockreplay/ephemeral: verify the replay's write-set produces no extra STATE vs the reference (value-vs-base, tolerating benign no-op writes the serial capture records); defer tx/doms cleanup so a mismatch reports instead of deadlocking - outputs.Diff: compare + report account Incarnation and codehash values - blockreplay: capture the BAL sidecar (Fixture.BALBytes) and feed it to the ephemeral source - replay_bench_test: fail (not skip) on a missing committed fixture - harness/memreader: guard genesis (block 0) parent underflow - exec3_parallel: drop redundant restoreTxNum when the caller resolved the range
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 24 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (5)
execution/tests/blockreplay/witness_domains.go:29
- This Diff() docstring claims it reports both extra and missing writes and is empty only on an exact key-set match, but the implementation delegates to writeSetDiff(), which only reports extra state-changing writes outside the reference set (and intentionally does not report missing reference keys). Please align the comment with the actual behavior to avoid confusing test failures and future callers.
// Diff returns the key-set differences between the replay's write-set and want,
// in both directions (extra and missing writes). Empty when they match exactly.
func (ws *WitnessWriteSet) Diff(want *Outputs) []string { return ws.mem.writeSetDiff(want) }
execution/tests/blockreplay/memreader.go:78
- In memBlockReader.headerAt,
number-1underflows whennumber==0. BLOCKHASH can legitimately request header 0 for low-number blocks, so it’s better to guard thenumber-1lookup to avoid relying on wraparound semantics (and accidental MaxUint64 map lookups).
h := &types.Header{Number: *uint256.NewInt(number)}
if prev, ok := r.ancestors[number-1]; ok {
h.ParentHash = prev
}
execution/tests/blockreplay/witness_domains.go:25
- The WitnessWriteSet comment says the verifier can require the replay write-set to equal the reference key set exactly, but writeSetDiff intentionally does not enforce exact key-set equality (it filters out reference no-op writes and focuses on extra state-changing writes). This comment is misleading for readers of the API.
This issue also appears on line 27 of the same file.
// caller runs the parallel executor against it and must never Flush.
// WitnessWriteSet exposes the replay's post-Seal write-set so the ephemeral
// verifier can require it to equal the reference output key set exactly.
type WitnessWriteSet struct{ mem *witnessMemBatch }
execution/stagedsync/exec3_ephemeral_test.go:164
- The verify() comment says it requires the replay write-set to equal the reference key set exactly (extra or missing writes fail), but WitnessWriteSet.Diff only flags extra state-changing writes outside the reference set. This mismatch makes the harness contract unclear.
// verify checks the post-state (Flush -> outputs read via the domains) against
// the authoritative canonical outputs — the data, not the trie root. It first
// requires the replay's write-set to equal the reference key set exactly (an
// extra or missing account/storage/code write fails), then compares values —
// so a state-only extra write commitment-off replay can't otherwise see is
// caught.
execution/tests/blockreplay/replay_bench_test.go:23
- PR description says fixture-backed tests "skip gracefully if it's absent", but this helper hard-fails when the fixture file is missing (and explicitly states it won’t skip). Please align either the PR description or the test behavior so CI expectations are clear (especially given the fixture is a multi-MB binary).
// The fixture is committed to testdata/; a missing file is a packaging
// regression, so fail loudly rather than skip (skips also fall outside the
// repo's allowed test-skip cases).
p := filepath.Join("testdata", "block-"+block+".gob")
yperbasis
left a comment
There was a problem hiding this comment.
Combined review (own findings + Codex + Copilot), verified against 2ead3c0 — build and the new tests were run locally; note CI has not run on this branch at all.
P1
Exec-only mode deadlocks bulk parallel replays. With DISCARD_COMMITMENT=true, commitResults is nil, so the calculator loop (for in != nil) exits immediately and never drains blockRequests — but blockRequests is still created whenever dbg.BALDrivenCommitment is on (default true, all chains). executeBlocks sends into it with a blocking select, so once a batch exceeds the 2048 buffer the exec loop blocks forever, the apply loop keeps waiting on applyResults, and nothing cancels the context. LoopBlockLimit defaults to 0, so bulk exec-only replay via stage_exec hangs. Gate blockRequests creation on the commit stream being live.
BenchmarkEphemeralParallelReplay breaks make test-bench and would eject the PR from the merge queue. test-bench runs -run='^$' -bench=. -benchtime=1x -short without DISCARD_COMMITMENT, and ci-gate calls it in merge_group; reproduced locally — b.Fatal at exec3_ephemeral_test.go:185. Since test-bench doesn't auto-run on PRs, the failure would first surface in the queue. The flag is read at package init, so the benchmark cannot self-enable it. Suggested fix for this and the previous point together: make exec-only an explicit option (an ExecuteBlockCfg field or ExecV3 parameter) instead of reading the env inside execImpl, and map DISCARD_COMMITMENT onto it at the stage boundary; the benchmark then sets the option directly.
HasStorage is not faithfully captured — CREATE/CREATE2 collision behavior can change at replay. recordingReader.HasStorage delegates without recording, while replay infers presence from captured slots. This flips both ways: storage that exists but was never slot-read answers false at replay (affects both Replay() and the witness-domains path), and a recorded zero-value read makes absent storage answer true on the serial path (inMemReader.HasStorage counts zero-valued entries; the witness path is immune because the production Writer drops zero writes). The answer feeds the EIP-684/7610 collision check (evm.go:524 falls through to stateReader.HasStorage). Capture a per-address HasStorage result in the fixture and serve it on both paths; the committed fixture then needs recapture (gob decodes the missing field as nil — handle nil or recapture).
P2
Deleted-account substate escapes verification. The blanket continue in writeSetDiff for keys under a want.Deleted account (witnessmembatch.go:208) also waives live-value writes, and CollectOutputs never scans deleted accounts — a replay that leaves or adds live storage/code under a deleted account verifies clean. Instead of skipping, require the post-value to be a tombstone and flag survivors; optionally also assert HasPrefix(deletedAddr) is false after replay.
Rebase + CI run needed. The branch is CONFLICTING and 70 commits behind, with conflicts exactly in the four refactored files; #22759 (de-variadic commitResults) and #22717 (BAL move) touch the same seams, so please re-verify behavior preservation across the rebase.
P3
Stale docstrings/description. The WitnessWriteSet type comment, its Diff docstring, and verify() in exec3_ephemeral_test.go still claim exact write-set matching, while writeSetDiff deliberately reports only extra state-changing writes (missing keys are caught by the value diff). The PR body also says fixture tests "skip gracefully if absent" — they now correctly fail loudly.
Call out the DISCARD_COMMITMENT semantic change. The parallel path previously computed and checked commitment even under the flag; now it is genuinely disabled, including under fork validation. Consistent with the serial path, but worth stating in the description as intentional.
captureAncestors swallows CanonicalHash errors, leaving holes that later surface as BLOCKHASH-returns-zero divergence at replay. Log or fail the capture.
…-core # Conflicts: # execution/stagedsync/exec3.go
… verification - exec3_parallel: gate blockRequests creation on the commit stream being live (commitResults != nil), so exec-only replay doesn't deadlock once a bulk batch exceeds the channel buffer (the calculator exits before draining it). - make exec-only an explicit ExecuteBlockCfg.discardCommitment field, initialized from dbg.DiscardCommitment() at the stage boundary and set directly by the ephemeral replay harness — so BenchmarkEphemeralParallelReplay self-enables it (the env is read only at package init) and no longer b.Fatal's under test-bench. - witnessmembatch: a live slot/code write under a self-destructed account is now flagged instead of blanket-skipped (CollectOutputs never rescans deleted accounts, so this was the only place to catch it). - harness: captureAncestors fails on a missing/errored canonical hash rather than leaving a BLOCKHASH-diverging hole. - fix docstrings that claimed exact write-set matching (writeSetDiff reports only extra state-changing writes; missing keys are caught by the value diff).
|
Rebased onto main and addressed the review — pushed Rebase. Merged current main. The only textual conflict was P1 — Exec-only deadlocks bulk parallel replays. Fixed — P1 — Benchmark breaks P1 — P2 — Deleted-account substate escapes verification. Fixed — P3. Note: as you flagged, CI hadn't run on this branch; it will now on the rebased head. |
Sync #22733 to current main-HEAD. Single conflict in exec3.go: the block source (src.next) already returns the BAL on-demand, so main's inline blockAccessListBytes loader is redundant here and would redeclare dbBAL — dropped it, keeping the src-provided value. Matches the resolution used in the downstream spine+interpreter branch merge. Build + vet clean; make lint clean; exec3/bal/blockreplay tests pass.
…d blocks A plain invalid block (e.g. INITCODE_SIZE_EXCEEDED) must propagate the error without scheduling a stage-level unwind: the caller (forkchoice for fork-validation, the sync loop for staged sync) owns the unwind. Setting one in unwindOnExecError left a stale bad-block verdict that prevented a fresh canonical block at the same height from being re-executed on the next fork-choice — so a fork-transition block replacing a rejected sibling was wrongly rejected. This restores main's ExecV3 error routing, which propagates plain-invalid errors and lets the caller drive the unwind. Also route all wrong-trie-root errors through handleIncorrectRootHashError regardless of cycle (drop the !IsInitialCycle gate), matching main. Fixes the eest-spec-tests zkevm-witness and blocktests-devnet shards: - zkevm-witness: 24041/0 (was 4 EIP-7954 fork-transition failures) - blocktests-devnet: 95518/0
|
Reviewed at head High1.
|
…-core # Conflicts: # execution/stagedsync/exec3_parallel.go
taratorio
left a comment
There was a problem hiding this comment.
Requesting changes for two additional blockers on head 1b6a2849.
The new exec-only benchmark currently panics in the parallel-commitment CI configuration, and multi-batch DISCARD_COMMITMENT execution can resume from an older commitment boundary after committing newer flat state. I reproduced the benchmark panic locally with:
ERIGON_COMMITMENT_PARALLEL=true go test ./execution/stagedsync -run '^$' -bench '^BenchmarkEphemeralParallelReplay$' -benchtime=1x -count=1
The payload-BAL regression and incomplete HasStorage witness are already covered by existing review feedback, so I have not duplicated those comments here.
| startBlockNum := blockNum | ||
| blockLimit := uint64(cfg.syncCfg.LoopBlockLimit) | ||
|
|
||
| doms.EnableParaTrieDB(cfg.db) |
There was a problem hiding this comment.
[P1] Skip parallel-trie activation for the seeded exec-only witness.
NewWitnessDomains seeds accounts, code, and storage through DomainPut before this call. With ERIGON_COMMITMENT_PARALLEL=true, NewSharedDomains has a pending parallel trie variant and those seed writes leave keys in the sequential update buffer. EnableParaTrieDB then panics with EnableParaTrieDB after touches: keys collected on the sequential buffer would be dropped.
This is the current bench / benchmarks (parallel) CI failure and reproduces locally. Since cfg.discardCommitment means no trie work should run, avoid this setup in that mode, or configure the trie before seeding the witness.
There was a problem hiding this comment.
Addressed — the trie setup (EnableParaTrieDB/EnableTrieWarmup/SetDeferCommitmentUpdates) is now gated on !cfg.discardCommitment (exec3.go), so exec-only mode never activates the parallel trie over the seeded witness. BenchmarkEphemeralParallelReplay (DISCARD_COMMITMENT=true ERIGON_COMMITMENT_PARALLEL=true) now passes instead of panicking. Commit 0cf5f43.
| // exits immediately, and closes rootResults, which the apply loop's normal | ||
| // close-handling absorbs. Used by ephemeral single-block replay over a flat | ||
| // witness (no trie). Real staged sync leaves this non-nil. | ||
| if pe.cfg.discardCommitment { |
There was a problem hiding this comment.
[P1] Do not resume exec-only batches from the stored commitment.
With this stream disabled, KeyCommitmentState remains at the pre-run boundary. Execution can still stop at the batch-size limit; the stage then records the newer Execution progress and the loop commits the newer flat state. On the next cycle, SpawnExecuteBlocksStage calls SeekCommitment, which prefers the existing old commitment state over its Execution-progress fallback. The executor therefore starts from the old block again, but over already-advanced state.
In discard mode, derive the resume point from Execution progress plus TxNums, or prevent partial batches from being committed. Please cover the two-cycle case on a DB that already has commitment state.
There was a problem hiding this comment.
Addressed — resume now derives from Execution progress in discard mode. The decision is extracted into resolveExecResumePoint (stage_execute.go): after SeekCommitment, when cfg.discardCommitment and Execution progress is past the (stale) commitment boundary, it resumes from Execution progress via TxnumReader().Max rather than re-running already-advanced blocks over their own post-state. TestResolveExecResumePoint covers the two-cycle case on a DB that already has commitment state, plus the no-advance and normal-commitment cases where the commitment boundary stays authoritative. Commit 1310340.
…tment) Exec-only mode (discardCommitment) runs no trie work. EnableParaTrieDB after the witness seed's DomainPut touches would panic on the dropped sequential-buffer keys under ERIGON_COMMITMENT_PARALLEL. Gate the whole trie setup (EnableParaTrieDB / EnableTrieWarmup / SetDeferCommitmentUpdates) on !discardCommitment so BenchmarkEphemeralParallelReplay (DISCARD_COMMITMENT + ERIGON_COMMITMENT_PARALLEL) no longer panics. Addresses the para-trie panic review comment on #22733.
Exec-only mode (discardCommitment) never advances KeyCommitmentState, so on a resumed run SeekCommitment still reports the pre-run boundary. When a prior size-limited batch flushed flat state past that boundary, Execution progress has moved ahead; resuming from the stale commitment boundary re-executes those already-advanced blocks over their own post-state. Extract the resume-point decision into resolveExecResumePoint: in exec-only mode, when Execution progress is ahead of the commitment boundary, resume from Execution progress (via TxnumReader().Max) instead. TestResolveExecResumePoint covers the two-cycle case on a DB that already has commitment state, plus the no-advance and normal-commitment cases where the commitment boundary stays authoritative. Addresses the exec-only resume review comment on #22733.
…-core Conflict in execution/stagedsync/exec3.go: main's #22190 added BAL-feed diagnostics to the inline decode block; this branch relocated BAL feeding into the block source (src.next -> blockAndBAL). Resolved by keeping the block-source structure and: - routing blockAndBAL through blockAccessListBytes so it prefers the payload-carried BAL and falls back to the DB sidecar (preserves #22190's payload-BAL preference / 0-re-exec behavior), and - re-adding the "executing block without a BAL" debug + TraceBALFeed diagnostics in executeBlocks, driven by the src-fed dbBAL.
|
Merged latest `main` ( Resolved semantically rather than take-HEAD (which would have dropped #22190's payload-BAL preference):
Verified post-merge: full build clean, `TestBlockAccessListBytes`, parallel-exec tests, and `BenchmarkEphemeralParallelReplay` (exec-only + para-trie) all pass; `make lint` clean. |
taratorio
left a comment
There was a problem hiding this comment.
Requesting changes for two execution/replay correctness issues. The focused tests and one-shot replay benchmark pass, but they do not cover these cases.
| return execErr | ||
| } | ||
|
|
||
| if errors.Is(execErr, ErrWrongTrieRoot) { |
There was a problem hiding this comment.
[P1] Keep wrong trie roots fatal during the initial cycle
Before this extraction, the parallel apply loop called handleIncorrectRootHashError only when !initialCycle; an initial-cycle root mismatch returned ErrWrongTrieRoot because there is no reorg to recover from. This router now sends every wrong-root error to the handler. The handler returns nil after scheduling an unwind, and it returns nil without an unwind when failedBlock <= s.BlockNumber, so initial sync can stop reporting a fatal state-root mismatch. Please return execErr when s.CurrentSyncCycle.IsInitialCycle and update the initial-cycle test accordingly.
There was a problem hiding this comment.
Addressed in dbfbabef70 — unwindOnExecError now returns execErr when s.CurrentSyncCycle.IsInitialCycle before routing to handleIncorrectRootHashError, so an initial-cycle wrong trie root stays fatal (no fork to recover from). The initial-cycle routing test now asserts it propagates ErrWrongTrieRoot with zero unwind calls (was asserting the swallowed-nil behavior).
| return v, ok, nil | ||
| } | ||
|
|
||
| func (r *recordingReader) HasStorage(address accounts.Address) (bool, error) { |
There was a problem hiding this comment.
[P1] Preserve HasStorage query results in the fixture
This delegates the query but never records its result. Replay later derives the answer from len(fx.Storage[address]), which describes slots that happened to be read, not whether the account has any live storage. This gives a false negative when storage exists only in an unread slot and a false positive after reading an absent or zero-valued slot. The EVM uses HasStorage for CREATE collision detection, so replay can take a different execution path. Please record the per-address answer and preserve it in both the in-memory and witness-backed replay paths.
There was a problem hiding this comment.
Addressed in 18bea18fd6 — the per-address answer is now captured (Fixture.HasStorageResult) at query time in recordingReader.HasStorage, and served in both replay paths: inMemReader.HasStorage returns the recorded value, and the witness path returns it from witnessMemBatch.HasPrefix(StorageDomain, addr) (which ReaderV3.HasStorage queries and uses only the bool). No longer inferred from read slots.
Pending review feedback on current head
|
…-core Conflicts in exec3.go and exec3_parallel.go from main's #23069 (pass the block around, remove redundant lookups) and #23053 (calcFees fewer vmap locks): - exec3.go: b is already fed by src.next(ctx); dropped main's inline ReadCanonicalHash/BlockWithSenders re-fetch (the lookup #23069 removes). - exec3_parallel.go RecentReceipts notify: use main's block-derived locals (blockNum/txs/header) — blockResult no longer carries BlockNum/Header/Txs — while keeping the branch's ephemeral-path nil guards (notifications / RecentReceipts non-nil) and !initialCycle.
…sync cycle The exec-core extraction routed every ErrWrongTrieRoot to handleIncorrectRootHashError, which returns nil after scheduling an unwind (or nil with no unwind when failedBlock <= s.BlockNumber). Initial sync has no competing fork to recover from, so this silently swallowed a state-root mismatch. Return execErr when s.CurrentSyncCycle.IsInitialCycle, before routing to recovery — restoring the pre-extraction behavior. Routing test updated to assert the initial-cycle wrong root propagates ErrWrongTrieRoot with no unwind. Addresses taratorio's P1 review comment on #22733.
…infer it recordingReader.HasStorage delegated to inner but never recorded the answer; replay inferred it from len(Storage[addr]), which reflects only slots that were read — a false negative when storage lives in an unread slot, a false positive after reading an absent/zero slot. The EVM uses HasStorage for CREATE collision detection, so replay could take a different path. Capture the per-address answer (Fixture.HasStorageResult) and serve it in both replay paths: inMemReader.HasStorage returns the recorded value, and the witness path's witnessMemBatch.HasPrefix (StorageDomain, addr) — which ReaderV3.HasStorage queries and uses only the bool — returns it too, since the seeded slots alone underreport it. Addresses taratorio's P1 review comment on #22733.
…nwind branch - unwindOnExecError doc claimed "any other invalid block unwinds to failedBlock-1 and marks it bad"; the impl returns execErr with no stage unwind point and the staged-sync loop that detects ErrInvalidBlock owns that unwind. Reconcile the doc to the impl and name each path's unwind owner. - Add a routing test with failedBlock (15) > s.BlockNumber (5) that exercises handleIncorrectRootHashError's binary-search branch and asserts the actual UnwindTo target (10). Prior tests only used failedBlock <= s.BlockNumber, which trips the early-return guard and asserts zero unwind calls. Addresses two items from taratorio's #22733 review checklist.
- fixtureChainReader.GetHeaderByNumber/GetHeaderByHash returned the parent for every query; validate the argument (parent iff number/hash matches, else nil), matching the existing GetHeader. - memBlockReader.headerAt guards the number-1 ancestor lookup for block 0 (number-1 would underflow to MaxUint64). - exec3 GetHash placeholder comment referenced execRoTx / a "serial path fallback" that parallel-only exec no longer has; it resolves ancestor headers via the block source. Addresses items from taratorio's #22733 review checklist.
…rd flag IsUnfrozenStepEdge read dbg.DiscardCommitment() directly while the stage also read it into ExecuteBlockCfg.discardCommitment — two independent reads of the same process-global. Thread the cfg value into SharedDomains via SetDiscardCommitment (called at ExecV3 start, covering both the stage and the ephemeral bench) and have IsUnfrozenStepEdge use the field. The cfg is now the single source of truth; non-exec SharedDomains default to false (compute commitment), which is the pre-existing behavior. Addresses an item from taratorio's #22733 review checklist.
- execV3 (was ExecV3) is only called within stagedsync (the stage and the ephemeral replay test) and takes package-internal types (execRange, blockSource); unexport it rather than expose an API no external caller can use. - Move the block capture/replay package execution/tests/blockreplay -> execution/blockreplay so the shipped cmd/integration binary (capture_block -> blockreplay.Capture) no longer imports a tests/ package. testdata moves with it; the bench's package-relative testdata/ path is unchanged. Addresses two items from taratorio's #22733 review checklist.
…in go test Add TestEphemeralParallelReplay: one exec-only parallel replay so a normal `go test` exercises the nil-commitResults topology (no commitment consumer), previously reached only by BenchmarkEphemeralParallelReplay. Also repoint fixturePath to execution/blockreplay/testdata after the package move (the bench uses the same helper). Addresses an item from taratorio's #22733 review checklist.
|
Worked through the checklist — all items addressed (head `5cb07c8f56`): Correctness blockers
Behavior and test coverage
Documentation and API cleanup
CI running on the new head. |
Main removed the streaming commitment mode (#23191) and split ExecV3 into a parallel execV3 plus execV3Serial with a stage-resolved execRange (#22733). Resolutions: - Streaming dropped everywhere the bin work had extended it: the variant, its flag, the PickTrieVariant case, and the tests that enumerated it. - WithoutParallelCommitment is now main's WithSequentialCommitment, keeping the guard that leaves a bin datadir on the bin trie instead of demoting it to hex. - executeInParallel picks between execV3Serial and execV3 at the stage, in place of main's `!(dbg.Exec3Parallel || cfg.experimentalBAL)`; bin stays serial. - deferCommitmentUpdates gates SetDeferCommitmentUpdates in both executors, parallel=true in execV3 and false in execV3Serial, matching what each asked for and still refusing the request under bin. - LatestCommitmentState / restorePatriciaState keep the StatefulTrie interface check, which subsumes main's per-variant type switch.
Goals
(
ExecuteBlockEphemerally) is serial-only and separate from the parallelExecV3, so single-block replays can't exercise (or catch bugs in) theparallel executor. This makes the parallel path drivable for a discrete
single block.
ExecV3to be parallel-only without removing serial exec.ExecV3sheds itsStageState/Unwindercoupling and becomes astage-independent parallel core; the legacy serial path is kept but moves to
execV3Serial, now managed by the stage process (SpawnExecuteBlocksStage),which also owns SeekCommitment/restoreTxNum/progress-update and the bad-block
unwind.
Why: to allow discrete, repeatable block testing on the real parallel
execution path — in particular replaying outlier blocks captured from tip
processing offline, for debugging and profiling, without standing up a node.
What's here
Extraction (
execution/stagedsync)ExecV3is parallel-only,u/StageState-free; returns an outcome the stagewrapper uses for progress update + unwind.
SpawnExecuteBlocksStageasunwindOnExecError(+TestUnwindOnExecError, 8 cases).execV3Serial, called by the stage.blockSourcefor block input;execctx.WithMemBatchto backSharedDomainswith a supplied in-memory batch;DISCARD_COMMITMENTnils the commit stream so the parallel path runsexec-only (calculator idles, apply-loop exit is driven by exec, not the trie
root).
notificationsderef in the parallel apply loop.Ephemeral harness (
execution/tests/blockreplay)ExecV3with no MDBX behind thestate (witness mem batch + in-memory
FullBlockReader, commitment off).(post-state read from the committed chain at end-of-block — never re-derived
by an executor, since serial is being decommissioned). The replay's post-state
is checked against them (data, not the trie root; receipts/gas/bloom are
already validated by the executor).
integration capture_block --block=Nproduces fixtures from a node.Testing
TestUnwindOnExecError(pins the hoisted unwind routing; mutation-verified).blockreplaypackage tests (capture/replay round-trip, seed readback,authoritative-outputs presence) and
BenchmarkEphemeralParallelReplay(validates the parallel replay's post-state against the canonical outputs each
iteration). The parallel replay of mainnet 25604144 passes at ~88ms/op.
stagedsync+execmodulesuites green;make lintclean.Notes for review
DISCARD_COMMITMENTdefault-false leaves the staged consensus path unchanged(the two prior behaviours are exactly preserved).
testdata/block-25604144.gobis ~3.4 MB. Fixture-backedtests skip gracefully if it's absent; happy to relocate it to a downloaded
asset if preferred rather than commit a binary of that size.
(this PR idles it), a top-down hoist of mem-batch construction out of
tx.Debug(), and wiring the mem-batch Flush for consumers that need outboundpost-state.