execution/stagedsync: prune in-RAM overlay when execution unwind is a disk no-op - #21824
Conversation
… disk no-op When a block fails its post-execution gas check mid-batch, its writes sit in the in-RAM SharedDomains / TemporalMemBatch overlay but were never committed. The committed execution-stage progress is therefore at or below the unwind point, so UnwindExecutionStage took the `u.UnwindPoint >= s.BlockNumber` early return and never called sd.Unwind — the overlay prune added by #20625 only runs via unwindExec3. The same overlay is reused across the unwind/retry loop inside one sync.Run, so the stale write survived and the re-execution read it. Observed on Hoodi block 3004265: ca5daf64 slot0 kept tx19's own first-write (...a3a34), the contract took the "already initialised" branch, skipped an SSTORE_SET (20000 gas), gasUsed came up exactly 21045 short, and the node spun in an unwind/retry loop. The committed DB was correct (slot0 = 0); only the overlay was stale. Prune the overlay to the unwind boundary in that branch too, and add a regression test driving UnwindExecutionStage on the no-op-disk-unwind path. Relates to #21681 Co-authored-by: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes an execution-stage unwind edge case where a disk unwind becomes a no-op (u.UnwindPoint >= s.BlockNumber) but the in-RAM SharedDomains/TemporalMemBatch overlay can still contain uncommitted writes from a failed mid-batch execution, causing stale reads and repeated gas-used mismatches/infinite unwind→retry loops.
Changes:
- In
UnwindExecutionStage, when the disk unwind would be skipped, still compute the unwind boundarytxNumand calldoms.Unwind(txNum, nil)to prune uncommitted overlay writes. - Add a regression test that seeds overlay writes above/below the unwind boundary and asserts only the above-boundary write is pruned in the no-op-disk-unwind path.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
execution/stagedsync/stage_execute.go |
Ensures in-RAM overlay is pruned even when execution unwind is a disk no-op, preventing stale overlay reads across retry loops. |
execution/stagedsync/stage_execute_unwind_test.go |
Adds a focused regression test validating overlay pruning occurs in the early-return (disk no-op) unwind branch. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…nd is a disk no-op (#21826) Cherry-pick of #21824 (release/3.4) / #21825 (main) to `release/3.5`. `release/3.5` has the identical gap: `UnwindExecutionStage` (`execution/stagedsync/stage_execute.go`) early-returns when `u.UnwindPoint >= s.BlockNumber` without calling `sd.Unwind`, so the in-RAM `SharedDomains` / `TemporalMemBatch` overlay is not pruned when a block fails its post-execution gas check mid-batch (before its step is committed). The same overlay is reused across the unwind→retry loop in `sync.Run`, so the stale write survives and the re-execution reads it — skipping an `SSTORE_SET`, undercharging gas, and looping. Full root-cause analysis in #21824. ## Adaptations Clean cherry-pick of the `main` commit (`release/3.5` is main-like): the early return is byte-identical, `stage_execute_unwind_test.go` does not exist on `release/3.5` so the new test is added fresh, and `common.Address`/`common.Hash` are raw array types (the test slices them with `[:]`). No manual conflict resolution was needed. ## Verification on release/3.5 + this patch - `go build ./execution/stagedsync/...` — clean - `go test ./execution/stagedsync/ -run TestUnwindExecutionStage_PrunesUncommittedOverlayWrite` — pass - red-check (revert just the prune): the test fails with the stale value `d7549f2a…a3a34` surviving the unwind, then passes with the fix restored - `gofmt` / `golangci-lint run ./execution/stagedsync/` — 0 issues Relates to #21681. Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: JkLondon <me@ilyamikheev.com> Co-authored-by: Claude <noreply@anthropic.com>
| @@ -469,6 +469,25 @@ func SpawnExecuteBlocksStage(s *StageState, u Unwinder, doms *execctx.SharedDoma | |||
| func UnwindExecutionStage(u *UnwindState, s *StageState, doms *execctx.SharedDomains, rwTx kv.TemporalRwTx, ctx context.Context, cfg ExecuteBlockCfg, logger log.Logger) (err error) { | |||
| //fmt.Printf("unwind: %d -> %d\n", u.CurrentBlockNumber, u.UnwindPoint) | |||
| if u.UnwindPoint >= s.BlockNumber { | |||
There was a problem hiding this comment.
ok. but smells like: this if is for early-return, probably instead of adding biz-logic inside this if - need modify it's condition logic so it "doesn't early return in some cases. Because maybe unwind has (or will have in future) important biz-logic which doesn't exists in current if. For example: it's unclear for me why this if doesn't have u.Done(rwTx) (outer biz-logic has it)
… disk no-op (erigontech#21825) Forward-port of erigontech#21824 to `main`. `main` has the identical gap: `UnwindExecutionStage` (`execution/stagedsync/stage_execute.go`) early-returns when `u.UnwindPoint >= s.BlockNumber` without calling `sd.Unwind`, so the in-RAM `SharedDomains` / `TemporalMemBatch` overlay is not pruned when a block fails its post-execution gas check mid-batch (before its step is committed). The same overlay is reused across the unwind→retry loop in `sync.Run`, so the stale write survives and the re-execution reads it — skipping an `SSTORE_SET`, undercharging gas, and looping. Full root-cause analysis in erigontech#21824. ## Adaptations vs the release/3.4 PR - **`stage_execute.go`**: clean cherry-pick (the early return is byte-identical on `main`). - **`stage_execute_unwind_test.go`**: created fresh on `main`. The file is release/3.4-only there (its existing `TestFindExecutedDiffsetAtHeight…` test was never forward-ported and is unrelated to this fix), so only the new test is added. `common.Address`/`common.Hash` are raw array types on `main` (no `.Bytes()` method), so the test slices them with `[:]` instead. ## Verification on main + this patch - `go build ./execution/stagedsync/...` — clean - `go test ./execution/stagedsync/ -run TestUnwindExecutionStage_PrunesUncommittedOverlayWrite` — pass - red-check (revert just the prune): the test fails with the stale value `d7549f2a…a3a34` surviving the unwind, then passes with the fix restored - `gofmt` — clean Relates to erigontech#21681. Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: JkLondon <me@ilyamikheev.com> Co-authored-by: Claude <noreply@anthropic.com>
yperbasis
left a comment
There was a problem hiding this comment.
Two substantive points and a couple of nits (reviewed statically against the PR branch).
1. Prune boundary is unwindPoint+1, but re-execution resumes from committed progress
The no-op path correctly does not call u.Done (lowering progress to unwindPoint would be wrong — (C, unwindPoint] was never flushed), so the Execution-stage progress stays at the committed value C (= s.BlockNumber). Re-execution then resumes from doms.SeekCommitment (exec3.go:125) = C+1, not unwindPoint+1.
With BatchCommitments=true (the default / snapshotter mode), commitment state is persisted only on the ~20s timer or when the batch fills, so C can sit several blocks below unwindPoint. The prune at Min(unwindPoint+1) then keeps the overlay writes for blocks (C, unwindPoint], but those blocks are re-executed from C+1 and re-read their own stale overlay — the same bug class. It doesn't loop forever (the unwind point walks back to C, then the prune clears everything — ~2 cycles), but it costs an extra partial re-execution and emits spurious "gas used mismatch" warnings for blocks that aren't actually bad. For the reported incident the two boundaries coincide (C == unwindPoint, i.e. one block since the last commit), which is why it works there.
Suggest pruning to the committed boundary instead — Min(ctx, rwTx, s.BlockNumber+1) (or the SeekCommitment txNum). That clears all uncommitted overlay in one step, aligns the prune with the resume point, and can't over-prune (committed state is in the DB).
This interacts with the test: keepKey is at block 7, above committedBlock=5 and below unwindPoint=7, and is asserted to survive ("no over-pruning"). But under resume-from-committed, block 7 is re-executed, so keeping its write is exactly what triggers the extra cycle — adopting the committed boundary flips that assertion.
2. State cache isn't reverted on this path
GetLatest consults sd.stateCache after the overlay (domain_shared.go:407); the disk path calls stateCache.RevertWithDiffset(...), the no-op path doesn't. Mitigated in practice — both executors call ValidateAndPrepare per block (exec3_serial.go:118, exec3_parallel.go:754), which clears the cache on retry, and the snapshotter never attaches a cache (SetStateCache is execmodule-only). So not a hole today, but the fix leans on that implicit clear rather than the explicit revert; worth a note for anyone enabling the cache on other paths that share this chokepoint.
Nits
doms.SetTxNum(txNum)is overwritten by the next run'sSeekCommitment→ redundant (harmless; mirrors the disk path).- The regression test exercises the prune mechanism directly (no state cache, no end-to-end retry loop) — reasonable scope, but it can't catch either point above.
…o-op execution unwind (#21847) Follow-up to #21824, addressing review feedback from @yperbasis and @AskAlexSharov. ## The gap in #21824 #21824 made `UnwindExecutionStage`'s no-op-disk-unwind branch prune the in-RAM overlay, but to `Min(u.UnwindPoint+1)`. That branch deliberately does **not** call `u.Done`, so re-execution resumes from the *committed* progress (`doms.SeekCommitment == s.BlockNumber+1`), **not** `u.UnwindPoint+1`. When committed progress sits below `u.UnwindPoint` — common with `BatchCommitments` (commitment is persisted on a ~20s timer or when the batch fills), so `s.BlockNumber` can be several blocks behind — the overlay writes for blocks `(s.BlockNumber, u.UnwindPoint]` are **kept** but then re-executed from `s.BlockNumber+1`, where they re-read their own stale overlay: the same bug class, just deferred. It converges (the unwind point walks back to the committed block, then the prune clears everything) instead of looping forever, but it wastes a partial re-execution and emits spurious `gas used mismatch` warnings for blocks that aren't actually bad. For the original Hoodi 3004265 incident `s.BlockNumber == u.UnwindPoint` (one block since the last commit), so the two boundaries coincided and #21824 worked. ## This PR - **Prune to the committed boundary** (`Min(s.BlockNumber+1)`) instead of `Min(u.UnwindPoint+1)`. Clears all uncommitted overlay in one step, matches the resume point, can't over-prune (committed state is in the DB). - **Restructure** `UnwindExecutionStage` into an explicit `disk-unwind` / `overlay-only` `if`/`else` with a shared `SeekCommitment` tail, so the no-op case is no longer a bare early-return that buries logic / risks skipping future shared steps (@AskAlexSharov). This also drops the redundant `SetTxNum` (@yperbasis nit — `SeekCommitment` sets it) and documents **why** the no-op path must not call `u.Done`, plus the state-cache caveat (@yperbasis #2: not a hole today — executors clear the cache per-block via `ValidateAndPrepare` and the snapshotter attaches no cache — but the path relies on that implicit clear). - **Test**: now asserts the `(s.BlockNumber, u.UnwindPoint]` write is pruned too, not just the failed block's; a write at/below committed still survives. ## Verification - red→green: with the merged `Min(u.UnwindPoint+1)` boundary the new test fails (the `(committed, unwindPoint]` write `0x1122` survives); with this PR it passes. - `go build ./execution/stagedsync/...` — clean - `go test ./execution/stagedsync/ -run 'TestUnwindExecutionStage_PrunesUncommittedOverlayWrite|TestFindExecutedDiffsetAtHeight'` — pass - `gofmt` / `golangci-lint run ./execution/stagedsync/` — 0 issues Relates to #21681. Will be forward-ported to `main` and `release/3.5` (which carry #21825 / #21826). Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: JkLondon <me@ilyamikheev.com> Co-authored-by: Claude <noreply@anthropic.com>
…rigontech#21973) Part of erigontech#21860. Adds integration and stage-level regression tests for the unwind/reorg state-correctness issues tracked in erigontech#21860, built around a self-checking, storage-churning contract so that any state left inconsistent by a faulty unwind or reorg surfaces as a failed transaction or a wrong read. The multi-step domain-unwind fix this work uncovered (erigontech#21981) is already merged into main. Production changes: `findExecutedDiffsetAtHeight` is extracted from `unwindExec3` so the erigontech#21515 fallback can be unit-tested, plus fixes for two latent infrastructure bugs the new tests surfaced (see Fixes below). ## `StateChurn` contract A fixed ring of 16 pseudo-random storage keys; each `poke(seed)` overwrites the next key round-robin with `keccak(seed, cursor) % 3`. So ~⅓ of writes set a slot to 0 (deletion) and slots are continually deleted and recreated — the storage pattern that exposes buggy unwinds. `trackedSum` is maintained incrementally and `require`d to equal the sum recomputed from storage. ## Regression tests (red-on-revert — fail without the fix) - **`TestUnwindExecutionStage_PrunesUncommittedOverlayWrite`** (erigontech#21681 / erigontech#21824) — when a block fails mid-batch before its step is committed, the no-disk-rollback execution unwind must still prune the uncommitted in-RAM `SharedDomains` overlay to the committed boundary; otherwise the stale writes survive into the unwind→retry loop and re-execution reads a wrong value. - **`TestFindExecutedDiffsetAtHeight_FallsBackAfterCanonicalReorg`** (erigontech#21515) — on a reorg the canonical hash for the unwound range is cleared, so the diffset lookup (keyed on the canonical hash) must fall back to the stored header; otherwise the unwind silently no-ops and the unwound block's state survives as phantom data (→ `CREATE2` collision → gas-used mismatch). Tested at the stage level because the path is only reachable through the full staged-sync pipeline. - **`TestEngineApiUnwindAcrossDomainStepBoundaries`** (erigontech#21981) — full node: builds real domain snapshot files, then unwinds a multi-step range crossing several domain-step boundaries and checks the StateChurn invariant against ground truth. Fails on main without the multi-step-unwind fix. ## Integration coverage (unwind/reorg correctness) - **`TestEngineApiUnwindRedoStateChurnPreservesState`** — one node records `trackedSum` at every height, then unwinds and redoes across shallow and deep targets, asserting live state matches the recorded value at each. Catches "head moved but state not rolled back". - **`TestEngineApiReorgToSideChainSwitchesStateChurn`** — reorgs a victim onto a competing side chain that churns different storage, asserting live state switches to the side fork. ## Fixes (latent bugs the new tests surfaced) - **`db/state` — data race between the background merge and chain-tip worker presets.** The merge path read per-domain compressor/accessor worker counts that chain-tip execution (`PresetChainTipConcurrency`) mutates concurrently; only the collate/build path pinned them. `mergeLoop` now pins worker config for its duration, and the pin is reentrant so an overlapping background build and merge can't re-enable edits mid-operation. (Surfaced by the `-race` run of `TestEngineApiUnwindAcrossDomainStepBoundaries`.) - **`db/kv/temporal` — the state aggregator was never released at shutdown.** `DB.Close()` closed the underlying MDBX and the forkable aggregators but not the state aggregator it owns, leaving its snapshot-file mmaps open. Harmless on POSIX, but on Windows an open mmap can't be unlinked, so deleting the datadir after shutdown failed. `DB.Close()` now closes the state aggregator too, before the MDBX handle, so its background goroutines release their read transactions first.
…g/unwind gaps (erigontech#22300) Part of erigontech#21860, follow-up to erigontech#21973. Revert-testing every root-cause fix behind the bugs cited in erigontech#21860 showed that several failure classes were invisible to the existing integration tests — only their purpose-built unit/stage tests went red. This PR extends the StateChurn suite and the exec-module tests to close the gaps that are honestly reachable from outside, and documents the ones that are not. ## New coverage - **Restart durability** — `TestEngineApiUnwindAcrossDomainStepBoundaries` now closes the node after its 60-block unwind, restarts on the same datadir (carrying the mock-CL state), re-asserts the churn invariant and keeps churning. Unwind correctness must be durable in MDBX+files rather than masked by the in-RAM overlay / unwind-changeset of the original process; this also covers the "corruption survives restart" property that made erigontech#21515 painful. - **Fork bounce** — `TestEngineApiForkBounceStateChurn` bounces canonical → side → canonical → side with full churn asserts after every switch, then keeps building on the final fork. Targets per-height state (diffsets, changesets, caches) leaking across fork switches. - **Re-orgs with pruning interfering** (the scenario erigontech#21860 names explicitly; previously the tester had no prune wiring at all) — `TestEngineApiReorgWithPruningInterference` runs a custom bite-sized prune distance (standard modes' 100k+ block distances never engage at test scale), so collate+prune runs on every forkchoice, interleaved with a shallow unwind+redo, continued churn, and a deep unwind into prune territory that must either be performed exactly or rejected loudly. - **Reorg across contract creation at the same address** — `TestEngineApiReorgAcrossContractCreationAtSameAddress` deploys the churn contract on both forks from the same sender+nonce (same address, diverging deploy blocks) and reorgs across the creation in both directions: leftover code/nonce from the losing fork triggers the EIP-684 create-collision behind the phantom-CREATE2 incidents (erigontech#20995), leftover storage trips the churn invariant. - **Unwind to the snapshot-file boundary** — `TestEngineApiUnwindToSnapshotBoundaryPreservesDeletedSlots` unwinds to just above the visible-files boundary after pruning has evacuated the filed range from MDBX, so post-unwind reads resolve through the snapshot files (verified: thousands of file reads) rather than being masked by MDBX chain-tip retention as in the shallower files test. - **Over-deep unwind rejection** — `TestEngineApiUnwindBeyondRetainedChangesetsRejectedCleanly` (no `AlwaysGenerateChangesets`) pins that a forkchoice below the retained history fails loudly — never silently no-ops or partially applies — and that the head stays restorable with correct reads afterwards. - **Bad block mid-stream (devp2p path)** — `TestUpdateForkChoiceBadBlockMidBatchThenRecovery` and `TestUpdateForkChoiceBadBlockAtLongBatchTailThenRecovery` feed the module never-validated segments containing a block that fails its post-execution gas check (mid-batch, first-of-batch, and at the tail of a 9-block segment), pinning that the bad block is rejected without condemning valid ancestors and that the untampered sibling at the same height recovers cleanly. Nothing previously exercised post-execution rejection outside `newPayload` validation. Shared helpers: `churnAndAssert` (continue poking with per-block invariant asserts), `buildRecordedChurnChain`, and a variadic transact-opts tweak on `buildChurnChain` so a fork can diverge at the deploy transaction itself. ## Red-on-revert verification Each test was verified against semantic reverts of the fixes it guards: - files+restart test: red under reverts of erigontech#21981 (multi-step unwind), erigontech#20483 read side (tombstone-as-miss), erigontech#20710 (per-step Flush orphans); - snapshot-boundary test: red under the erigontech#20483 read-side revert; - bounce, prune and create-collision tests: red when `StateCache.Unwind` invalidation is disabled; prune test additionally red when `StateCache.Delete` is disabled. ## Classes that remain guarded at unit/stage level only (verified unreachable from outside on current main) - erigontech#21824/erigontech#21848 overlay prune on no-op unwind: the module aborts and rolls back its FCU tx on a bad block instead of in-loop retrying, and each FCU gets a fresh SharedDomains — the stage-level test remains the guard. - erigontech#21157 diffset fallback: `updateForkChoice` unwinds *before* rewriting canonical markers within one atomic MDBX tx, so the cleared-markers-before-unwind ordering has no producer in current flows; the stage-level test remains the guard. - erigontech#20483 write side (unwind loses deletion markers): instrumentation shows the lost tombstone restores have no observable effect at any test scale because unmerged file layers retain deletion markers — the production resurrection required mainnet-scale compaction. `TestDomain_UnwindRestoresDeletionMarker` remains the guard. - erigontech#21088 hash-aware changeset lookup: both call sites live behind batch/streaming-commitment paths inactive at chain tip. ## Bugs surfaced while building these - erigontech#22298 — after a rejected bad-tip FCU, forkchoice to the already-canonical valid ancestor re-executes the rejected block (the module tests pin the working recovery path; a regression test for the broken path is a one-line change described in the issue). - erigontech#22299 — txpool pending nonce stays durably stale after fork bounces; `churnAndAssert` pins the nonce from latest state as a workaround. - erigontech#22301 — a too-deep FCU passes the changeset-based unwind gate, partially unwinds, fails at `SeekCommitment`, and wedges block production; the rejection test stops at the read-side contract and the issue carries the one-line extension that reproduces the wedge.
) v3.4.3 hit the known post-reorg 'gas used mismatch' bug family (erigontech/erigon#21824/#21847, 4th variant #22423): execution declares canonical mainnet block 25393073 invalid (txnIdx=1158, gas 59338474 vs header 59632361), unwinds one block, retries, fails identically — wedged at block 25392043 since 2026-06-25, 302k blocks behind tip. Restarts do not clear it. v3.4.4 shipped the third fix for this bug class; the v3.5 line carries the rest. v3.5.4 is current stable (2026-07-29). In-place datadir upgrade, no resync required. Fixes hachiari/bearbullbunny#1305 (42d-old block ages on /ethereum)
What
Fixes a
gas used by executionmismatch that puts the node into an infiniteunwind/retry loop. Observed on the Hoodi snapshotter (e3.4.3, serial batch
execution) stuck on block 3004265:
Root cause
#20625/#21538correctly prunes the in-RAMTemporalMemBatchoverlay bytxNuminsideTemporalMemBatch.Unwind(for accounts and storage). Thatprune is reached from
unwindExec3.The gap is one level up, in
UnwindExecutionStage:When a block fails its post-execution gas check mid-batch, its writes are
already in the overlay but the block was never committed, so the committed
execution-stage progress
s.BlockNumberis at or below the unwind point andthis early return fires. The overlay prune is skipped entirely. Because the
same
SharedDomainsis reused across the unwind→retry loop inside onesync.Run, the stale write survives:SSTOREtoca5daf64slot0 → overlay holds…a3a34at tx19's txNum.UnwindTo(3004264)→UnwindExecutionStageno-ops (3004264 ≥ committed progress) → overlay not pruned.
…a3a34instead of the committed0, takes the"already initialised" branch, skips the
SSTORE_SET(20000 gas) + compute →block is 21045 gas short → fails again → loops forever.
The committed DB is correct (
eth_getStorageAt ca5daf64 0x0=0x0); only thein-memory overlay was stale, which is why the state root still matches and
only
gasUseddiverges.Fix
In the
u.UnwindPoint >= s.BlockNumberbranch, still prune the in-RAM overlayto the unwind boundary (same
txNum/sd.Unwindcall the non-no-op pathalready uses). When the overlay has no uncommitted writes above the unwind
point (the normal case) this is a harmless no-op.
Test
TestUnwindExecutionStage_PrunesUncommittedOverlayWriteseeds the overlay witha first-time storage write at a
txNumbelonging to a block above the unwindpoint, drives
UnwindExecutionStagewithu.UnwindPoint >= s.BlockNumber, andasserts the stale write is gone while a write at/below the unwind point
survives.
the stale value (
d7549f2a…a3a34, the real bug value).Verification
go build ./execution/stagedsync/... ./db/state/...— cleango test ./execution/stagedsync/ -short— passTestSharedDomain_Unwind*(the#20625regression tests) — still passTestFindExecutedDiffsetAtHeight_FallsBackAfterCanonicalReorg— still passgofmt/go vet/golangci-lint run ./execution/stagedsync/— 0 issuesNotes
Relates to #21681 (the
gas used mismatch in 3.4reports that persist after#20625). Not auto-closing — the same root cause is the most likelyexplanation for those, but they should be confirmed on the snapshotter (incl.
block 25202264) first.
The fix lives in the single execution-unwind chokepoint, so it covers both the
serial and parallel executors. A forward-port to
mainwill be openedseparately.
Co-authored-by: Claude noreply@anthropic.com