Skip to content

execution/stagedsync: unwind overlay-prune keeps one extra (system) txNum; unify boundary to last committed txNum #21863

Description

@yperbasis

Summary

Follow-up to #21847 (and #21824). The in-RAM overlay prune in execution unwind keeps one extra txNum — the first (system) txNum of the first re-executed block — because both unwind paths pass Min(<first re-executed block>) to SharedDomains.Unwind, while TemporalMemBatch.Unwind keeps entries with txNum <= unwindToTxNum. The boundary should be the last committed txNum so that all re-executed blocks' overlay writes are dropped, and the two unwind paths should share a single boundary.

Surfaced by the Copilot review on #21847 (the second review batch, against stage_execute.go:523 and the regression test).

Detail

TemporalMemBatch.Unwind keeps e.txNum <= unwindToTxNum (db/state/temporal_mem_batch.go:541):

if e.txNum <= unwindToTxNum { kept = append(kept, e) }

Both unwind paths compute the boundary as Min(<first re-executed block>) — i.e. that block's first/system txNum:

In both cases Min(resumeBlock) is the first system txNum of a block that gets re-executed, and the <= keep-condition leaves that single overlay entry in place.

Why this is hardening, not a live bug

The leaked entry has not caused observable issues (the disk-path convention has carried it since #20625), because:

  1. Re-execution shadows it — the re-executed block re-writes the same key at the same txNum; getLatest returns the last-appended entry, so every downstream (higher-txNum) reader sees the fresh value.
  2. The retry reproduces the identical value — the no-op path retries the same canonical block, so its system tx writes the same bytes (V_stale == V_new).
  3. System-tx writes are unconditional (EIP-4788 / EIP-2935 ring buffers) — no gas-sensitive SLOAD-before-SSTORE, and they don't feed the user-tx gasUsed that triggers the unwind.

The only theoretical victim is a block-begin system op doing a gas-sensitive read-before-write of a slot it itself wrote in the same (failed) execution — which doesn't occur today. The point of this issue is to make the boundary enforce the invariant ("drop everything that will be re-executed") instead of relying on those three properties holding for every future system-level operation.

The inconsistency to resolve

Today both paths use Min(resumeBlock) — consistent with each other, but both leak the boundary txNum. The Copilot review on #21847 suggests tightening only the overlay-only path to Max(s.BlockNumber); doing that in isolation makes the two paths diverge (overlay-only = last committed txNum, disk = first re-executed txNum). So the resolution should pick one convention and apply it to both.

Proposed fix (recommended)

Use the last committed txNum as the prune boundary on both paths, factored into a single shared helper so they can't drift again:

  • Overlay-only: TxnumReader().Max(s.BlockNumber) (= Min(s.BlockNumber+1) - 1).
  • Disk: TxnumReader().Max(u.UnwindPoint) for the sd.Unwind boundary (re-execution still resumes at u.UnwindPoint+1).

TxNumsReader.Max already exists (db/kv/rawdbv3/txnum.go:204). Max(committedBlock) is also marginally more robust than Min(resumeBlock) — it references only the committed block, which is always present in the txnum index, whereas resumeBlock need not be.

Care on the disk path: txUnwindTo also feeds sd.SetTxNum(txUnwindTo) (stage_execute.go:343) and the non-nil-changeset unwindChangeset fallback inside TemporalMemBatch.Unwind. Verify both still behave with the tightened value; SetTxNum is overwritten by the trailing SeekCommitment regardless, and the existing TestSharedDomain_Unwind* regression tests cover the diffset fallback.

Acceptance criteria

  • Both unwind paths prune the overlay to the last committed txNum, via one shared boundary.
  • TestUnwindExecutionStage_PrunesUncommittedOverlayWrite gains a case: an overlay write at exactly Min(committedBlock+1) (the first system tx of the first re-executed block) is pruned, while a write at/below the last committed txNum survives.

Links

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions