Skip to content

[r3.8] cl: stop fsyncing fork-graph dumps, validate boundary blocks without a state copy, and republish moved heads and queued envelopes - #25287

Merged
yperbasis merged 4 commits into
release/3.8from
cherry-pick-25251-25253-25258-25259-r38
Oct 23, 2026
Merged

yperbasis merged 4 commits into
release/3.8from
cherry-pick-25251-25253-25258-25259-r38

Conversation

@domiwei

@domiwei domiwei commented Oct 23, 2026

Copy link
Copy Markdown
Member

Cherry-pick of #25251, #25253, #25258 and #25259 to release/3.8.

All four apply without conflicts and need no release-specific changes. They were picked from the PR branches while the PRs were still in the main merge queue.

DumpBeaconStateOnDisk and DumpEnvelopeOnDisk called File.Sync() on every
dump. Both files live in path.Join(dirs.Tmp, "caplin-forkchoice"), which
Caplin removes and recreates on every start (cmd/caplin/caplin1/run.go;
the removal came with the dump and its Sync in #8546). It is the only
production fork-graph filesystem, and no dump file is read after a
restart, so fsync's durability guarantee (the content survives a power
loss or kernel crash) protects nothing.

Inside the running process the files stay usable without fsync:
- reads see written data through the page cache, fsynced or not;
- all content reads and writes of these files hold stateDumpLock
  (Prune removals and the hasBeaconState existence check run outside
  it, as before);
- if a head-state dump cannot be read, replay walks back to an older
  dump when the block is still kept, or returns no state when only the
  header is kept (for example the anchor), the same as for any missing
  dump;
- envelopes are still written to a .tmp file, closed with a checked
  Close, and renamed, so readers never see a partial file, and a write,
  flush, close or rename error keeps the existing final file.

History: the head-state Sync came with the original feature (#8546,
which gives no rationale; its error was always returned) and was moved in
#8851. The envelope dump copied it in #18956, and #23548 moved envelopes
to tmp+rename and kept the Sync. No change relies on the durability of
these files.

Cost, measured on a Hoodi node (1.14M validators): each head-state dump
writes about 143 MB every 4th slot. dumpMs is p50 ~540 ms, p90 ~1.06 s,
max 2.65 s, while encoding takes ~4 ms and snappy + write ~100 ms
(measured locally). Every dump holds stateDumpLock (also needed by fork
graph replay, req/resp envelope reads and, after Gloas, envelope import
while it holds the fork-choice f.mu). The head dump after fork choice
also runs under the synced-data read lock and inside the stage's
indiciesDB RW transaction. A shorter dump shortens all of these.

Trade-off: fsync is also the point where the kernel reliably reports a
delayed writeback error (EIO). close(2) on a local filesystem usually
does not report it. Such an error may now go unnoticed by the dump and
show up later, for example when the data is read again after the pages
were evicted. Before, the dump itself failed. A head-state read error
makes replay fall back as described above. An envelope that fails to
decode is marked invalid and can be fetched again. If the re-read itself
returns EIO, the envelope stays marked as present, so normal ingestion
skips it while the error lasts: until a later read succeeds, the anchor
envelope is rewritten, or the block is pruned. This needs a failing
disk, and it is accepted here for the cost above.

The head-state dump closed its file with an unchecked defer. It now
closes it once in a defer that joins the Close error into the result,
like the envelope dump, so errors reported at close (for example on NFS)
are not lost.

The finalized-state save under dirs.CaplinLatest
(saveFinalizedStateOnDiskIfNeeded / replaceDurableFile) is the restart
anchor and keeps its fsyncs.

Tests: with a file whose Sync fails, both dumps must succeed and read
back equal; with a file whose Close fails, the head-state dump must
return the error and close once. The "sync" stage of
TestDumpEnvelopeFailurePreservesExistingFinal is removed because Sync is
no longer called.

(cherry picked from commit a859869)
…locks from the parent's proposer lookahead

Gossip validation of the first block of an epoch copied the parent state
and ran ProcessSlots across the epoch boundary to read the expected
proposer, the latest block hash and the latest bid. From Fulu on, epoch
processing only shifts proposer_lookahead and does not write the latest
block hash or the latest bid, so the parent state already holds these
values when no fork upgrade happens in between.

Extract the lookahead read of GetBeaconProposerIndexForSlot into
ProposerIndexFromLookahead, which reports whether the slot is inside the
lookahead window. computeBlockValidationContext now also reads a parent
from an earlier epoch in place when the parent is at most MinSeedLookahead
epochs back, the scheduled state version is the same at both epochs and
at least Fulu, the viewed state has that version, and the lookahead covers
the slot. Fork activation epochs and parents further back still use the
copy path.

(cherry picked from commit bdaa78e)
…tart

The proposer boost expires when a slot starts, so the fork choice head can
move back to another branch without a new block. SleepForSlot ends at that
moment, but it went to ChainTipSync without checking for an unpublished
head, and ChainTipSync can wait a slot or longer for the next block. Until
then the synced head state, the EL forkchoice and the Beacon API head stayed
on the old head, and block production failed its head-change check because
the head state never caught up with fork choice.

SleepForSlot now routes an unpublished head to ForkChoice like the stages
that import blocks. The stage args also tick fork choice to the start of the
slot they target before they read the head, so the boost expiry is seen even
when the fork choice clock ticker has not reached the slot yet.

(cherry picked from commit fda0346)
…yload envelopes

An envelope that arrives before its beacon block is ignored on gossip and
queued until the block is known. Gossipsub does not forward ignored
messages, so when the queued envelope later validated it was processed
locally but never relayed. Peers that only hear the envelope through
Caplin then miss it.

Publish the envelope through the gossip manager once it is processed
from the pending queue. Lighthouse, Prysm and Teku also propagate an
envelope after deferred validation. go-libp2p-pubsub validates and
forwards a local publish of a message that was seen but ignored.

(cherry picked from commit 57fe39d)
Copilot AI balanced review requested due to automatic review settings October 23, 2026 10:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@domiwei
domiwei requested review from awskii and lystopad and a balanced review from Copilot October 23, 2026 10:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@domiwei
domiwei enabled auto-merge October 23, 2026 10:01

@lystopad lystopad left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the picks: all four commits are patch-identical to the #25251/#25253/#25258/#25259 branches, and fork_graph, state, stages and the network-service tests pass with -race on this branch. I also checked #25251's premise on release/3.8: caplin-forkchoice under dirs.Tmp is removed on every start (run.go:365), and torn dumps are caught by the read path, so dropping the fsync is safe.

Since the four main PRs are still in the merge queue, I'll approve once they merge and the merge commits match these picks.

@yperbasis yperbasis added this to the 3.8.1 milestone Oct 23, 2026
@yperbasis
yperbasis disabled auto-merge October 23, 2026 11:32
@yperbasis
yperbasis added this pull request to the merge queue Oct 23, 2026
Merged via the queue into release/3.8 with commit 18eb995 Oct 23, 2026
257 of 259 checks passed
@yperbasis
yperbasis deleted the cherry-pick-25251-25253-25258-25259-r38 branch October 23, 2026 12:12
kriss39 pushed a commit to kriss39/erigon that referenced this pull request Oct 27, 2026
…tart (erigontech#25258)

## Problem

The proposer boost expires when a slot starts, so the fork choice head
can move back to another branch without a new block. When SleepForSlot
ended at that moment, its transition could send a node that was behind
to ChainTipSync even though `ArgsFunc` had marked the fork choice head
as unpublished, and ChainTipSync can wait up to 25 seconds for the next
block. Until the ForkChoice stage ran, the synced head state and the
state-based Beacon API reads stayed on the old head, and the EL was not
sent the new head.

On Gloas, block production started from the old synced head state and
saw a different fork choice head. It waits for the synced state to catch
up before it retries, but nothing published the new head before the
proposal cutoff, so no retry started and it returned the head-change
error. We saw this in a local multi-client network after a network
partition ended: a Caplin node that had to propose right after the boost
expired did not produce its block. Before Gloas, block production does
not check for a head change and builds on the synced head when the
validator client asks, so the change helps there only when the
publication comes first. On large states that is less likely.

## What changed

- SleepForSlot's transition uses `catchUpPublishingHead`, which was
`catchUpAfterImport`, like the stages that import blocks. When
`MetaCatchingUp` would select ChainTipSync and the head is unpublished,
it selects ForkChoice first.
- The stage `ArgsFunc` ticks fork choice to the start of the current
slot before it reads the head, so the boost expiry is processed even
when the 2 ms fork choice ticker has not reached the slot yet. `OnTick`
only moves the stored time forward, and after genesis the start of the
current slot is never later than the wall clock. If `GetHead` fails
there, the error is logged at Debug and the head counts as published.
- At the tip, CleanupAndPruning goes back to ForkChoice when the head is
still unpublished. Gossip imports blocks outside the stage loop, so the
slot's block can arrive during the slot-start ForkChoice pass or during
CleanupAndPruning. Before Gloas, SleepForSlot does not poll the head, so
without this the block would be published at the next slot start, about
ten seconds late. Before this PR, ChainTipSync was waiting at that
moment and published the block on arrival.
- The rerun is allowed once per `sleepForSlotRewakeInterval` (1 s). A
publish that keeps failing therefore costs at most one extra pass per
second, and CleanupAndPruning still runs between the passes.
- The bound is time-only. A second head change within that second waits
for the next slot start before Gloas. Before this PR such a head was not
published before the next slot either.
- The check is not in the ForkChoice transition: CleanupAndPruning runs
right after it and also covers a block that arrives during
CleanupAndPruning.
- Dropping the Gloas gate on the SleepForSlot head poll would also close
the gap. It is not used because on pre-Gloas networks attestations
invalidate the head cache, so it would add five head computations a
second under the fork choice lock and wake the stage on every
attestation-driven head move.

A race remains: the ticker advances the stored time with a lock-free
compare-and-swap and takes the fork choice lock afterwards to clear the
boost and the head cache. Readers that get the lock in between, for
example the `GetHead` in `ArgsFunc`, can still see the cached, boosted
head. The window is the time the ticker waits for the lock, not a single
preemption point. Closing it needs the cached head to carry the slot it
was computed at, which this PR does not do.

This PR does not change the fork choice weight calculation or branch
selection. The added tick only processes the boost expiry earlier, so
the existing rules can select another branch at the slot start sooner
than before.

## Tests

- `TestCatchUpPublishesForkChoiceHeadBeforeChainTipSync`:
- SleepForSlot selects ForkChoice when the node is behind, has peers and
has an unpublished head. Before the change it selected ChainTipSync.
- At the tip with an unpublished head, ForkChoice goes on to
CleanupAndPruning, and CleanupAndPruning goes back to ForkChoice.
- CleanupAndPruning goes to SleepForSlot when the head is published,
when the node has no peers and is behind, and when a rerun happened less
than a second ago.
- `TestCleanupAndPruningRerunsForkChoiceOncePerInterval`: two
transitions on the same `Cfg` give ForkChoice and then SleepForSlot.
- `TestStageArgsTickForkChoiceToTheTargetSlot`: the fork choice store
starts one slot behind the clock, and the test checks that `ArgsFunc`
moves it to the target slot. Before the change the store stayed at the
anchor slot.
- Each change has a test that fails without it:
  - the SleepForSlot transition change;
  - the `OnTick` call;
  - the CleanupAndPruning rerun;
  - each of its three conditions;
  - the timestamp update.
- No test checks that `OnTick` runs before `GetHead`: that needs a head
that changes when the proposer boost expires, and this package has no
such fixture.
- `go test ./cl/phase1/stages/ ./cl/phase1/forkchoice/...` and `-race`
on `./cl/phase1/stages/` pass. `make lint` reports 0 issues, and `make
erigon integration` builds.

## Follow-ups

These are not in this PR:
- the tick and boost race above;
- SleepForSlot with no peers still goes to ChainTipSync;
- a slot boundary inside ForkChoice or CleanupAndPruning;
- `payload_attributes` events with a past `proposal_slot` when the head
goes back to an older slot.

This change is on release/3.8 through erigontech#25287. The rerun commits need a
separate backport.
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.

4 participants