Repository navigation
cl/phase1/forkchoice/fork_graph: stop fsyncing fork-graph dump files - #25251
Merged
Merged
Conversation
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.
yperbasis
approved these changes
Oct 23, 2026
yperbasis
left a comment
Member
There was a problem hiding this comment.
Approving.
Non-blocking:
- The two "Dump state periodically for restart checkpoints" comments in
cl/phase1/stages/forward_sync.gonow contradict the argument this PR rests on, since the dumps are wiped on start. Worth rewording them here. - Please file tracking issues for the two follow-ups in the description (dump after
tx.Commit()to stop the silent rollback;f.muheld while envelope import waits forstateDumpLock) so they do not get lost. - 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.
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
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.
This was referenced Oct 27, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Caplin's fork graph writes two kinds of dump files and calls
File.Sync()on each one:DumpBeaconStateOnDisk(cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go), every 4th slot (dumpSlotFrequency = 4);DumpEnvelopeOnDisk(same file), after Gloas.On a Hoodi node (1.14M validators) one head-state dump writes about 143 MB.
dumpMsis 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:
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-choicef.mu(cl/phase1/forkchoice/on_execution_payload.go:780,:1176);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;indiciesDBRW 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:
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 useafero.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 incl/phase1/stages/forward_sync.gocall 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:
stateDumpLock(fork_graph_disk_fs.go:57,:122,:205,:354).Pruneremovals and thehasBeaconStateexistence check run outside it (fork_graph_disk.go:755,:850-858); this is unchanged.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..tmpfile, closed with a checkedClose, 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: removedSync(). The file was closed by an uncheckeddefer. It is now closed once in a defer that joins theCloseerror into the returned error, like the envelope dump, so errors reported at close (for example on NFS) are not lost.DumpEnvelopeOnDisk: removedSync(). The order stays write, flush, checkedClose,Rename.What is not changed
dirs.CaplinLatest(saveFinalizedStateOnDiskIfNeeded/replaceDurableFile,cl/phase1/stages/forkchoice.go:372-430) is the restart anchor. It keeps its file fsync, rename and directory fsync.stateDumpLock, and the head dump after fork choice still runs under the synced-data read lock and inside theindiciesDBRW transaction. This PR only makes the dumps shorter.Follow-ups (not in this PR):
tx.Commit(). This also fixes a silent rollback: today a failed dump makespostForkchoiceOperationsreturn an error, so the canonical-chain and finalized-slot writes in the same transaction are rolled back.f.muwhile it waits forstateDumpLock(on_execution_payload.go:780→:1176), so a long head-state dump still stalls fork choice. Either releasef.muaround persistence or give envelopes their own lock and buffer.Tests
TestForkGraphDumpsDoNotRequireSync: with an afero file whoseSync()fails,NewForkGraphDisk(forced anchor dump) andDumpEnvelopeOnDiskmust succeed, and the state and the envelope must read back with equal SSZ hashes. Fails onmainwith the injected Sync error.TestDumpBeaconStateReturnsCloseError: with an afero file whoseClose()fails,DumpBeaconStateOnDiskmust return that error and close the file exactly once. Fails onmainbecause 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 exceptcl/spectestTest/mainnet_config, which needs the downloadedmainnetfixtures (not present locally, unrelated to this change).make lintis clean.Backport
Not planned for release/3.8.