Skip to content

cl/phase1/forkchoice/fork_graph: stop fsyncing fork-graph dump files - #25251

Merged
domiwei merged 1 commit into
mainfrom
kewei/cl-forkchoice-dump-no-sync
Oct 23, 2026
Merged

domiwei merged 1 commit into
mainfrom
kewei/cl-forkchoice-dump-no-sync

Conversation

@domiwei

@domiwei domiwei commented Oct 22, 2026

Copy link
Copy Markdown
Member

Problem

Caplin's fork graph writes two kinds of dump files and calls File.Sync() on each one:

  • the head state, in DumpBeaconStateOnDisk (cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go), every 4th slot (dumpSlotFrequency = 4);
  • the execution payload envelope, in DumpEnvelopeOnDisk (same file), after Gloas.

On a Hoodi node (1.14M validators) one head-state dump writes about 143 MB. dumpMs is p50 ~540 ms, p90 ~1.06 s and max 2.65 s. Measured locally, encoding takes ~4 ms and snappy + write ~100 ms, so most of the node time is likely writeback and fsync. The split on the node was not measured separately.

Every dump holds a shared lock, and the head dump after fork choice also holds a lock and a transaction, so every millisecond counts:

  • every dump holds stateDumpLock (fork_graph_disk_fs.go:122). The same lock is needed by fork-graph replay (readBeaconStateFromDisk, :57), by req/resp and API envelope reads (ReadEnvelopeFromDisk, :205), and after Gloas by envelope import (DumpEnvelopeOnDisk, :354), which runs while it holds the fork-choice f.mu (cl/phase1/forkchoice/on_execution_payload.go:780, :1176);
  • the head dump after fork choice runs inside syncedData.ViewHeadState, which holds the synced-data read lock (cl/phase1/stages/forkchoice.go:469, cl/beacon/synced_data/synced_data.go:167). A waiting writer makes new readers queue behind it;
  • it also runs inside the stage's indiciesDB RW transaction (cl/phase1/stages/forkchoice.go:498-518).

Why removing fsync is safe

The durability that fsync gives (data survives a power loss or a kernel crash) is never used, because nothing reads these files after such an event:

  • Both files live in path.Join(dirs.Tmp, "caplin-forkchoice"). Caplin removes and recreates this directory on every start, before it builds the fork graph (cmd/caplin/caplin1/run.go:365-384). This is the only production fork-graph filesystem; the spectests use afero.NewMemMapFs() (cl/spectest/consensus_tests/fork_choice.go:314). The removal came in the same PR that added the dump and its Sync (Forkchoice states saved on disk #8546). Comments in cl/phase1/stages/forward_sync.go call the forward-sync dumps "restart checkpoints", but they are also wiped on start; the real restart anchor is the finalized-state save.

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 (fork_graph_disk_fs.go:57, :122, :205, :354). Prune removals and the hasBeaconState existence check run outside it (fork_graph_disk.go:755, :850-858); this is unchanged.
  • If a head-state dump cannot be read, replay walks back to an older dump when the block is still kept (fork_graph_disk.go:639-645), or returns no state when only the header is kept, for example for the anchor (:623-633). This is the same as for any missing dump.
  • Envelopes are still written to a .tmp file, closed with a checked Close, and renamed (fork_graph_disk_fs.go:377-427). Readers never see a partial file, and a write, flush, close or rename error keeps the existing final file (TestDumpEnvelopeFailurePreservesExistingFinal).

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 the pattern in #18956, and #23548 moved envelopes to tmp+rename and kept the Sync. No change relies on the durability of these files.

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.

What changed

  • DumpBeaconStateOnDisk: removed Sync(). The file was closed by an unchecked defer. It is now closed once in a defer that joins the Close error into the returned error, like the envelope dump, so errors reported at close (for example on NFS) are not lost.
  • DumpEnvelopeOnDisk: removed Sync(). The order stays write, flush, checked Close, Rename.

What is not changed

  • The finalized-state save under dirs.CaplinLatest (saveFinalizedStateOnDiskIfNeeded / replaceDurableFile, cl/phase1/stages/forkchoice.go:372-430) is the restart anchor. It keeps its file fsync, rename and directory fsync.
  • Every dump still holds stateDumpLock, and the head dump after fork choice still runs under the synced-data read lock and inside the indiciesDB RW transaction. This PR only makes the dumps shorter.

Follow-ups (not in this PR):

  • Move the head-state dump and the finalized-state save after tx.Commit(). This also fixes a silent rollback: today a failed dump makes postForkchoiceOperations return an error, so the canonical-chain and finalized-slot writes in the same transaction are rolled back.
  • Gloas envelope import holds the fork-choice f.mu while it waits for stateDumpLock (on_execution_payload.go:780 → :1176), so a long head-state dump still stalls fork choice. Either release f.mu around persistence or give envelopes their own lock and buffer.

Tests

  • TestForkGraphDumpsDoNotRequireSync: with an afero file whose Sync() fails, NewForkGraphDisk (forced anchor dump) and DumpEnvelopeOnDisk must succeed, and the state and the envelope must read back with equal SSZ hashes. Fails on main with the injected Sync error.
  • TestDumpBeaconStateReturnsCloseError: with an afero file whose Close() fails, DumpBeaconStateOnDisk must return that error and close the file exactly once. Fails on main because the error was dropped.
  • TestDumpEnvelopeFailurePreservesExistingFinal: removed the "sync" stage, because Sync is no longer called. The open, write, close and rename stages still pass.
  • go test -race ./cl/phase1/forkchoice/... passes. go test ./cl/... passes except cl/spectest Test/mainnet_config, which needs the downloaded mainnet fixtures (not present locally, unrelated to this change). make lint is clean.

Backport

Not planned for release/3.8.

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.
Copilot AI balanced review requested due to automatic review settings October 22, 2026 19:21
@domiwei domiwei added performance Caplin Caplin: Consensus Layer, Beacon API labels Oct 22, 2026
@domiwei
domiwei requested review from lystopad and yperbasis October 22, 2026 19:21

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.

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.

🟢 Approval recommended

The focused changes preserve flushing, locking, and atomic envelope replacement, with targeted regression coverage and no unresolved findings.

No open issues 🎉

Review effort: Balanced

@yperbasis yperbasis 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.

Approving.

Non-blocking:

  1. The two "Dump state periodically for restart checkpoints" comments in cl/phase1/stages/forward_sync.go now contradict the argument this PR rests on, since the dumps are wiped on start. Worth rewording them here.
  2. Please file tracking issues for the two follow-ups in the description (dump after tx.Commit() to stop the silent rollback; f.mu held while envelope import waits for stateDumpLock) so they do not get lost.
  3. release/3.8 has the same fsync calls and this commit cherry-picks onto it cleanly. Mainnet carries a similar validator count, so a [r3.8] backport looks worthwhile.

@domiwei
domiwei added this pull request to the merge queue Oct 23, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 23, 2026
@domiwei
domiwei added this pull request to the merge queue Oct 23, 2026
github-merge-queue Bot pushed a commit that referenced this pull request Oct 23, 2026
…ithout a state copy, and republish moved heads and queued envelopes (#25287)

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

- #25251: stop fsyncing fork-graph dump files
- #25253: validate boundary blocks without a state copy
- #25258: publish a fork choice head that moved at the slot start
- #25259: republish queued execution payload envelopes

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.
Merged via the queue into main with commit ff98243 Oct 23, 2026
257 of 259 checks passed
@domiwei
domiwei deleted the kewei/cl-forkchoice-dump-no-sync branch October 23, 2026 13:36
joxerx pushed a commit to joxerx/erigon that referenced this pull request Nov 1, 2026
…ad state (erigontech#25539)

## Problem

On Gloas the ForkChoice stage runs again for the same head root when the
head's payload status changes from EMPTY to FULL (`GetExecutionHead`
changes and `sleep_for_slot.go` wakes the stage), and again about once
per second while the forkchoice update returns busy. Each run calls
`postForkchoiceOperations`, which dumps the head state with
`DumpBeaconStateOnDisk`. The envelope does not change the beacon state,
so every extra run encodes and rewrites the same state for the same
root.

A head-state dump takes about 405 ms p50 on a mainnet node with the
current dump code (no fsync since erigontech#25251), and it holds `stateDumpLock`,
which disk state reads (replays) also need. By code reasoning this can
add up to 8 redundant dumps per epoch on Gloas (one per dump slot, more
while the forkchoice update is busy). There is no Gloas mainnet data for
this yet.

## Fix

`forkGraphDisk` remembers the block root and slot of its last successful
state dump, guarded by `stateDumpLock`. `DumpBeaconStateOnDisk` returns
early when both match. The slot is part of the key because a state
advanced through empty slots keeps its block root but has different
content. The marker is cleared before each write and set only after the
write finished and the file closed without error, so a failed or
panicked write is retried (stage panics are recovered, so the next run
would otherwise skip a truncated file).

## Tests

New tests in `fork_graph_test.go` count state-file writes through the
existing failing-fs wrapper:
- the same root and slot dumped twice writes once (red on main: 2
writes);
- a close error is retried (`TestDumpBeaconStateReturnsCloseError`);
- a panic during the write is retried
(`TestDumpBeaconStateRetriesAfterPanic`);
- a different root is still written;
- the same root at a different slot is still written, and the file holds
the later state;
- a failed later-slot write does not block a new dump of the earlier
slot.

Each test fails when the production branch it targets is removed.

## Out of scope

- `stateDumpLock` contention between state dumps and replays in general,
and making the state dump atomic (temp file + rename).
- Avoiding the repeated ForkChoice stage run itself.

Related to erigontech#25120 and erigontech#25136.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Caplin Caplin: Consensus Layer, Beacon API performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants