Skip to content

fix: reachable replay anchor - #307

Merged
skylenet merged 2 commits into
ethpandaops:masterfrom
jochem-brouwer:fix/reachable-replay-anchor
Aug 12, 2026
Merged

fix: reachable replay anchor#307
skylenet merged 2 commits into
ethpandaops:masterfrom
jochem-brouwer:fix/reachable-replay-anchor

Conversation

@jochem-brouwer

Copy link
Copy Markdown
Contributor

Due to changes to the execution api https://github.com/ethereum/execution-apis/pull/786/changes and to Geth ethereum/go-ethereum#34767 the reorg logic of Geth changed which causes problems on jochemnet compute tests, where an attempted reorg derails the entire storage and makes it impossible to run any tests on it.

Prestates are built by advancing a snapshot with forkchoice calls that set head = safe = finalized on every block, so the block every fixture replays from arrives already finalized. Geth won't move its head back to a block at or below what it considers finalized. That stays invisible until a test drifts the head far enough that the anchor's state is no longer retained (test_blockhash builds 258 blocks), after which the next test's first newPayload names a parent whose state the client can't serve, gets ACCEPTED, and every later payload is orphaned: 4,213 of 4,884 tests in one jochemnet compute run, which still exited 0.

This PR sends one forkchoice per instance pointing safe/finalized at a block strictly below the head, and prefixes each test's replay with a forkchoice back to its anchor, so the client is asked for the anchor rather than assumed to be on it. It also scopes SkipUntilBlockNumber to the pre-run step, which is what its doc always claimed: it was running on every step file and silently dropping that new leading forkchoice before it was sent. Verified under exact CI conditions: the two-test reproduction goes from 516 executor failures to 0, and the full 4,884-test compute suite passes with none.

A prestate keeps whatever forkchoice markers it was built with. Prestates are
produced by advancing a snapshot with forkchoiceUpdated calls setting
head = safe = finalized on every block (builder/engine.go,
builder/prerun_bundle.go), so the block they end on — the one every fixture
replays from — arrives already finalized.

geth will not move its head back to a block at or below the one it considers
finalized: it answers "Skipping beacon update to finalized ancestor" with
VALID and leaves the head alone. That stays invisible until a test moves the
head far enough that the anchor's state is no longer retained. test_blockhash
builds 256 dummy blocks to fill the BLOCKHASH window, putting the head 258
blocks out; the next test's first newPayload then names a parent whose state
the client cannot serve, is answered ACCEPTED, and every later payload is
orphaned. One such test stranded 4,213 of 4,884 tests in a jochemnet compute
run, which still exited 0.

Send one forkchoiceUpdated per instance, before any test, pointing safe and
finalized at a block strictly below the head, and prefix each test's replay
with a forkchoiceUpdated back to its anchor. On the reproduction the anchor
stops being finalized, the forkchoice rewinds 258 blocks, and eth_getBalance
at the anchor goes from "historical state ... is not available" to a balance.

The zero hash cannot do this: clients read it as "no update", which is what
lets the stale marker survive. It is also only permitted "unless transition
block is finalized" (ForkchoiceStateV1), which mainnet-fork datadirs do not
satisfy.

The anchor is resolved once and kept: a configured hash, else the client's own
finalized block when it already sits below the head, else the head's parent.
For a datadir built by advancing a snapshot, the snapshot block is the natural
configured value. This runs unconditionally rather than inside bootstrap_fcu,
an optional readiness probe most configs leave unset.
SkipUntilBlockNumber drops leading lines until the first engine_newPayload past
the datadir head, so a partly-applied pre-run bundle is not replayed from the
start. Its doc says "skip pre-run RPC lines", but it ran on every step file,
including each test's own setup and test steps.

That was invisible while every step file began with an engine_newPayload, which
satisfies the condition immediately. It stops being invisible as soon as a step
starts with anything else: a setup opening with a forkchoiceUpdated — the call
that brings the head back to the block the fixture replays from — had that line
removed before it was sent, and the client never saw it:

  Resuming pre-run replay resumed_at_block=24410464 resumed_at_line=2
                          skip_until_block=24410463 skipped_lines=1

A test's steps are never partly applied; only the pre-run replay resumes.

With this, the two-test reproduction goes from 516 executor failures to 0, and
the full 4,884-test compute suite passes with none.
@skylenet skylenet changed the title Reachable replay anchor fix: reachable replay anchor Aug 12, 2026
@skylenet
skylenet merged commit 1e0b9d4 into ethpandaops:master Aug 12, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants