Repository navigation
cl/persistence, cl/antiquary: store builders history as a per-index diff - #25231
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Dump-aligned restarts can bypass legacy-row detection and leave unfrozen database history unreadable after upgrading.
1 open issue
What changed in this PR
This PR replaces Caplin’s builders queue history with per-index diffs, reducing storage for in-place registry changes while retaining full dumps and the existing snapshot type.
Changes:
- Adds a versioned builders diff codec with validation before mutation.
- Integrates builders replay, missing-row detection, and rebuild-error backoff.
- Adds codec, reader, collector, snapshot-boundary tests, and a size benchmark.
| File | Description |
|---|---|
db/kv/tables.go |
Updates the builders table’s format comment. |
cl/persistence/state/historical_states_reader/historical_states_reader.go |
Adds builders-specific replay and shared dump loading. |
cl/persistence/state/historical_states_reader/gloas_roundtrip_test.go |
Adapts reconstruction tests to the new format. |
cl/persistence/state/historical_states_reader/builders_reader_test.go |
Tests replay, missing rows, and legacy-row rejection. |
cl/persistence/state/builders_diff.go |
Implements per-index encoding and validated application. |
cl/persistence/state/builders_diff_test.go |
Tests layout, updates, rejection, and decoder compatibility. |
cl/persistence/state/builders_diff_bench_test.go |
Compares per-index and queue encoding costs. |
cl/antiquary/state_prune_reader_test.go |
Tests replay across snapshot and database boundaries. |
cl/antiquary/state_antiquary.go |
Adds rebuild-required errors to backoff handling. |
cl/antiquary/state_antiquary_test.go |
Tests the new backoff condition. |
cl/antiquary/beacon_states_collector.go |
Switches builders collection to the new writer. |
cl/antiquary/beacon_states_collector_test.go |
Tests collector round trips and fork-transition handling. |
Review effort: Balanced
| errors.Is(err, historical_states_reader.ErrMissingHistoryVectorData) || | ||
| errors.Is(err, base_encoding.ErrCorruptQueueDiff) | ||
| errors.Is(err, base_encoding.ErrCorruptQueueDiff) || | ||
| errors.Is(err, state_accessors.ErrHistoryRebuildRequired) |
There was a problem hiding this comment.
Good catch, fixed. ReadBuildersSSZ now starts replay at the dump slot. It checks the format of the kv.Builders row there but does not apply it, because the dump already includes that row. A legacy row at a dump-aligned resume slot now returns ErrHistoryRebuildRequired, so the antiquary backs off further instead of resuming after the dump. A processed dump slot without a row is also rejected, the same as any other slot. A missed dump slot is still accepted. The regression test is TestReadBuildersSSZChecksTheRowAtTheDumpSlot. Commits: 3f47f1b and cfa8786.
861039b to
68cac61
Compare
cfa8786 to
58ace75
Compare
lystopad
left a comment
There was a problem hiding this comment.
LGTM. Reviewed head 58ace75f13 (the four commits on top of #25230).
The codec compares builders by value and validates everything (exact length, registry limit, no shrink, strictly increasing indices, contiguous appends) before it changes the list. The dump-aligned resume case Copilot flagged is fixed: ReadBuildersSSZ now format-checks the row at the dump slot, and the resume slot always has a canonical block and therefore a row. readRequiredDump returns the boundary or boundary+1, so no diff row is skipped. cl/persistence/... and cl/antiquary/... pass with -race.
Nits/notes:
- The description still says "review only the last two commits"; there are four now, and the SHAs in the reply to Copilot are pre-rebase (
4b8e56f514,58ace75f13now). - Release planning: the stack (#25230, #25231, #25234) should ship together in one release that reaches Hoodi operators before Gloas activates there, so no Hoodi node writes old-format rows.
- The published Sepolia state snapshots for 11250000–11300000 stay unreadable; worth a tracked follow-up to republish that range.
…lap (#25230) Fixes #25205 Stacked on #25212: review only the last four commits. The last commit updates one test of this PR for the `ReadRequiredQueueSSZ` signature from #25212. This PR must ship in the same release as #25231 (builders per-index diff) and #25234 (builder pending payments base plus deltas), which are stacked on it. Without them, a Gloas node writes `builders` rows that rewrite the whole list on every in-place change. ## Problem The state antiquary stores per-slot diffs of the Electra queues (`pending_deposits`, `pending_consolidations`, `pending_partial_withdrawals`) and the Gloas lists (`builders`, `builder_pending_withdrawals`, `payload_expected_withdrawals`) with `SSZQueueEncoder`. The encoder remembered only the previous size and the previous last element. It searched the new list backwards for that element and wrote "truncate N from the front" plus the elements after it. Two cases broke this: - `builders` changes in place (withdrawals, deposits, exits). When a builder that is not the last one changed, the diff was "truncate 0, append nothing", so the change was lost until the next dump. - When an appended entry equalled the previous last entry, the search picked the appended copy, the truncate count went negative and wrapped in `uint32`, and replay emptied the queue. ## Fix The diff byte format and the replay format are unchanged. - `Initialize` keeps a copy of the previous list's element pointers in a slice the encoder owns. - `WriteDiff` first tries a fast path based on pointer identity: queue elements are copy-on-write, so equal pointers hold equal values. If the previous list's tail and the new list's head hold the same pointers, it writes the diff directly. This covers unchanged queues, append-only slots and front pops plus appends. - Otherwise it finds the longest suffix of the previous list that equals a prefix of the new list with KMP, which is linear in the list sizes, and writes `truncate = prevLen - overlap` plus `new[overlap:]`. An in-place builder change becomes a full rewrite of the list for that slot. - `ApplySSZQueueDiff` returns the new `ErrCorruptQueueDiff` when the truncate count is larger than the queue length, before it changes the queue. The readers wrap it, and the antiquary resume backs off on it like it does for `ErrMissingGloasData`. ## Upgrade note There is no migration: the stored bytes of a wrong diff cannot be repaired, and each wrong window ends at the next dump (at most 1535 slots). Re-antiquation does not repair rows already frozen in Caplin state snapshot files, and nothing regenerates a frozen range automatically. On Sepolia the Gloas range written by older releases stays wrong. Electra ranges are affected only in the rare case where two equal queue entries were appended in a row. ## Tests - Collector tests: an in-place change to builder 0 is kept, and an appended duplicate builder pending withdrawal no longer empties the queue. - Encoder tests: in-place `Set` on the same list, duplicate last append, front cut plus postponed re-append, the fast path and its fallback when equal values have different pointers, `truncate == len` accepted and `truncate > len` rejected without changing the queue, an exhaustive round trip over all small lists with duplicates, and a check that the overlap search makes a linear number of equality calls. - Reader and antiquary tests: the corrupt-diff error is wrapped by both readers, and the antiquary backoff includes it. - Benchmark in `ssz_queue_bench_test.go`: at 100k `pending_deposits` entries, a diff takes about 0.24 ms. TDD: the tests were written first and failed on the old code for the intended reason, except the `truncate == len` check and the linear-call check, which also pass on the old encoder and guard the new one. The fast-path test failed before the fast path was added. ## Validation - `go test ./cl/persistence/... ./cl/antiquary/... -count=1` - `make lint`, two runs, 0 issues - `make erigon integration` ## Follow-ups (not in this PR) - `builders` gets its own per-index row format in #25231, so an in-place builder change no longer rewrites the whole list. - No end-to-end test drives the antiquary backoff through a stored corrupt row. - Copy-on-write of queue elements is a convention, not enforced by the type.
ReadBuildersSSZ treats a processed slot at or after the Gloas fork that has no kv.Builders row as an error. addGenesisState wrote SlotData at the genesis slot but no builders row, so a Gloas genesis or anchor state at a slot that is not a dump slot could not be read. Write the no-change row at the genesis slot.
… resumes detect legacy history ReadBuildersSSZ loads the dump and replays rows from the next slot, so a read at a dump slot never looked at any kv.Builders row. When the state antiquary picked a dump-aligned Gloas resume slot on a DB with legacy queue-format rows, the read succeeded, antiquation resumed after the dump and the earlier legacy rows were left in place. The reader now checks the format of the row at the dump slot (without applying it, because the dump already includes it). A legacy row there returns ErrHistoryRebuildRequired, which the antiquary already treats as a reason to back off further. A missing row at the dump slot is still accepted: the genesis/anchor dump has no row, and a missed dump slot has none either.
Replay now starts at the dump slot and only checks, not applies, the row there. A processed block at or after the fork whose dump-slot row is missing returns ErrHistoryRebuildRequired, like any other slot. A missed dump slot is still accepted, and the genesis slot now has a row.
58ace75 to
9616363
Compare
|
Correction to the upgrade note: #25234 is closed, so builder pending payments keep the legacy zstd row format. In this release only the builders rows change format. I updated the note in the description. |

Stacked on #25230 (which is stacked on #25212): review only the last two commits.
Problem
The state antiquary writes one
kv.Buildersrow per processed Gloas block withSSZQueueEncoder. Builders is a registry, not a queue: deposits, withdrawals and exits change builders in place. With the overlap encoder from #25205, any change to a builder that is not the last one rewrites the whole list for that slot, about 97 bytes per builder. At 200 builders that is about 19 KB per block, roughly 46–51 GB a year. The format has to change before Gloas reaches Hoodi and mainnet, because after that old rows would need a migration.Fix
kv.Buildersgets a per-index diff format. Same table, same snapshot type.kv.BuildersDumpand the other five queues are unchanged.Row layout, little-endian, not compressed:
26 + 101*count. A block with no builder change writes a 26-byte row. With one change at 200 builders a row is 127 bytes, against 19404 with the queue encoder.BuildersDiffWriter, keeps the pre-block builders as copied values. It writes every changed index plus the appended indices, in increasing order. The first Gloas row after the fork (empty baseline) carries the full list as appends. A shrink is an error.ApplyBuildersDiff, checks the whole row before it changes the list. It rejects:ReadBuildersSSZreplays the rows on top of the dump, using the same dump rules asReadRequiredQueueSSZ(cl/persistence: read Gloas queues before the first Gloas dump #25212). A processed block (one withkv.SlotData) at or after the fork slot that has no row is an error. A missed slot is skipped. The Gloas genesis state writes a no-change row at its own slot, so it can be read when its slot is not a dump slot.ErrHistoryRebuildRequired, and the antiquary backs off on it like it does forErrMissingGloasData.The first 8 zero bytes are for old binaries. An older reader takes them as "truncate 0", then a zero-length builder, and fails decoding the empty builder before it changes the list. It does not mistake the row for a corrupt queue diff, so it does not back off and rewrite new rows in the old format.
Upgrade note (for the release notes)
Hoodi operators must upgrade before the Gloas fork on 2026-10-26. This release changes the on-disk format of Caplin historical state rows for builders (per-index diff).
Tests
BenchmarkBuildersRowSize. At 200 builders with one change per block, Initialize plus WriteDiff takes about 1.7 µs, 7 allocs and 127 bytes per row.TDD: the codec, reader and backoff tests were written first and failed for the intended reason, either on undefined symbols or with the backoff predicate returning false. The genesis test also failed first: at slot 32 with
historical state row needs a rebuild: missing row from table Builders at slot 32, and at slot 0 because the genesis slot had no row (an empty value where a 26-byte row was expected). Some later tests, the append-end check, the hole after a dump, and the single-field changes, guard existing behaviour and passed on first run. Each one was checked to fail when its target branch is disabled.The snapshot file version is deliberately not bumped, so no Caplin snapshot republish is needed. The raw preamble tells old and new rows apart.
Validation
go test ./cl/persistence/... ./cl/antiquary/... ./db/snapshotsync/... -count=1, and-raceoncl/antiquarymake lint, two runs, 0 issuesmake erigon integrationFollow-ups (not in this PR)
LatestExecutionPayloadBidTablestores a redundant full bid per slot. It can be changed after the Hoodi fork.