Skip to content

cl/persistence, cl/antiquary: write queue diffs from the longest overlap - #25230

Merged
domiwei merged 4 commits into
mainfrom
kewei/ssz-queue-overlap
Oct 22, 2026
Merged

domiwei merged 4 commits into
mainfrom
kewei/ssz-queue-overlap

Conversation

@domiwei

@domiwei domiwei commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

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)

Copilot AI balanced review requested due to automatic review settings October 22, 2026 08:47
@domiwei domiwei added Glamsterdam https://eips.ethereum.org/EIPS/eip-7773 Caplin Caplin: Consensus Layer, Beacon API type:bug labels Oct 22, 2026
@domiwei domiwei self-assigned this Oct 22, 2026
@domiwei
domiwei requested review from lystopad and yperbasis October 22, 2026 08:47

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.

🔵 Needs a closer look

Persistent-state encoding changes, an unresolved overlap optimization issue, and required coordination with stacked Gloas fixes warrant human validation.

1 open issue
What changed in this PR

This PR fixes historical-state queue diffs in Caplin while preserving the stored byte format. It addresses lost in-place changes and duplicate entries that could produce invalid truncate counts.

Changes:

  • Retains previous queue pointers and adds pointer-based and value-based overlap searches.
  • Rejects oversized truncation, adds reader error context, and enables antiquary backoff on corrupt diffs.
  • Adds regression tests, exhaustive round-trip checks, and queue benchmarks.
File Description
cl/​persistence/​state/​historical_states_reader/​historical_states_reader.go Adds table and slot context to diff errors.
cl/​persistence/​state/​historical_states_reader/​gloas_roundtrip_test.go Tests corrupt-diff error wrapping in both readers.
cl/​persistence/​base_encoding/​ssz_queue.go Replaces tail matching with overlap searches and validates truncation.
cl/​persistence/​base_encoding/​ssz_queue_test.go Tests replacements, duplicates, overlap selection, and corruption handling.
cl/​persistence/​base_encoding/​ssz_queue_bench_test.go Benchmarks large pending-deposit queue diffs.
cl/​antiquary/​state_antiquary.go Includes corrupt queue diffs in resume backoff.
cl/​antiquary/​state_antiquary_test.go Tests backoff error classification.
cl/​antiquary/​beacon_states_collector_test.go Adds builder-change and duplicate-withdrawal regressions.

Review effort: Balanced

Comment thread cl/persistence/base_encoding/ssz_queue.go

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

LGTM. Reviewed head 861039b29d (the four commits on top of #25212).

Checked the copy-on-write premise of the pointer fast path against every builder mutation site (deposit, withdrawal, exit, top-up): all copy and Set a new pointer. KMP bounds and the pre-mutation truncate > len rejection look right; the antiquary back-off on ErrCorruptQueueDiff is bounded. cl/persistence/... and cl/antiquary/... pass with -race.

On the overlap-maximality point: agreed it's non-blocking, since replay is correct and the extra size is bounded by the slot's appended entries.

Reminder for release planning: this needs #25231 and #25234 in the same release.

SSZQueueEncoder kept only the previous size and last element. It lost
in-place changes of non-last entries (builders use copy-on-write Set), and
when an appended entry equalled the previous last one, the truncate length
went negative, wrapped to uint32, and replay emptied the queue.

The encoder now snapshots the previous element pointers in its own slice and
finds the longest suffix of the previous queue that equals a prefix of the
new one, using KMP so the cost stays linear in the queue sizes. The diff and
replay formats are unchanged; no migration is needed.

ApplySSZQueueDiff rejects a truncate larger than the queue with
ErrCorruptQueueDiff before it mutates the queue. The historical readers wrap
it with table and slot, and the state antiquary backs off on it as it does
for missing Gloas data, so it can re-antiquate from an earlier state.

Diffs already written by the old encoder are not rewritten. Archive nodes
that antiquated the Gloas range should re-antiquate it.

Fixes #25205
…keeps its element pointers

WriteDiff ran the KMP overlap over the whole queue every slot and called
equalFunc on each element. Queue elements are copy-on-write, so a pointer
that is still in the new queue still holds the same value.

Before KMP, the encoder now finds the first previous index k whose pointer
is the new head and checks that previous[k:] and new[:len(previous)-k]
hold the same pointers. If they do, the overlap is len(previous)-k.
Otherwise, or when the new queue is empty, it falls back to KMP, so the
diff is always correct. This covers unchanged queues, appends, FIFO pops
with appends and postponed deposits that are re-appended by pointer. The
type parameter is now comparable; all callers use pointer types.

BenchmarkSSZQueueEncoderPendingDeposits, 100k entries, Initialize +
WriteDiff per op, Apple M3 Pro:

  case              before                 after
  unchanged         818 us, 3.6 KB, 1      235 us, 0.9 KB, 1
  append16          825 us, 10.2 KB, 113   241 us, 7.5 KB, 113
  pop16append4      819 us, 5.3 KB, 29     239 us, 2.6 KB, 29
  setFirstFallback  14.5 ms, 41 MB, 700k   14.7 ms, 41 MB, 700k

The remaining cost of the fast cases is mostly the pointer copy in
Initialize.

Also remove TestSSZQueueDiffReplacementAndEmptyLists. Its cases are
covered by TestSSZQueueDiffExhaustiveMinimalOverlap and TestSSZQueue.
Replace three-index slice appends with slices.Concat to satisfy gocritic appendAssign and modernize slicesclip.
Copilot AI balanced review requested due to automatic review settings October 22, 2026 13:59
@domiwei
domiwei force-pushed the kewei/ssz-queue-overlap branch from 861039b to 68cac61 Compare October 22, 2026 13:59
@domiwei
domiwei enabled auto-merge October 22, 2026 14: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.

🔵 Needs a closer look

Historical-state persistence and recovery changes require final human validation and coordinated release with #25231 and #25234.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@domiwei
domiwei added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 21fc1c3 Oct 22, 2026
256 of 258 checks passed
@domiwei
domiwei deleted the kewei/ssz-queue-overlap branch October 22, 2026 15:52
rayjun pushed a commit to rayjun/erigon that referenced this pull request Oct 23, 2026
…iff (erigontech#25231)

Stacked on erigontech#25230 (which is stacked on erigontech#25212): review only the last two
commits.

## Problem

The state antiquary writes one `kv.Builders` row per processed Gloas
block with `SSZQueueEncoder`. Builders is a registry, not a queue:
deposits, withdrawals and exits change builders in place. With the
overlap encoder from erigontech#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.Builders` gets a per-index diff format. Same table, same snapshot
type. `kv.BuildersDump` and the other five queues are unchanged.

Row layout, little-endian, not compressed:

```
00 00 00 00 | 00 00 00 00 | 47 4c 4f 41 ("GLOA") | 01 (version) | 01 (kind)
newLength u64 | count u32 | count × (index u64, 93-byte Builder SSZ)
```

- The total length must be exactly `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.
- The writer, `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.
- The decoder, `ApplyBuildersDiff`, checks the whole row before it
changes the list. It rejects:
  - a wrong preamble, version or kind;
  - a wrong exact length;
  - a length above the registry limit, or a shrink;
- indices that are not strictly increasing, or not below the new length;
  - appends that are not contiguous;
  - a builder that does not decode.
- `ReadBuildersSSZ` replays the rows on top of the dump, using the same
dump rules as `ReadRequiredQueueSSZ` (erigontech#25212). A processed block (one
with `kv.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.
- Every rejection wraps the new `ErrHistoryRebuildRequired`, and the
antiquary backs off on it like it does for `ErrMissingGloasData`.

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) and builder pending payments (epoch base plus
per-block deltas, erigontech#25234).

- The Caplin state snapshot types keep their file version.
- A node that reads an old-format row from its DB backs off and
re-antiquates from before the Gloas fork.
- An older release that reads a new-format row fails with a decode error
and does not read wrong data. Downgrading after new rows were written is
not supported.
- erigontech#25205 must not ship without this format change.
- Sepolia is not repaired. Rows already frozen in Caplin state snapshot
files, including the published Sepolia range 11250000–11300000, stay in
the old format. Snapshot files are read before the DB and frozen ranges
are not regenerated, so historical state reads that replay those rows
keep failing until the range is reset or republished.
- The same applies to a node that passed a Gloas fork on an older
release and has already frozen Gloas state snapshots.

## Tests

- **Codec:**
- round trip over: no change, first, middle or last change, several
changes, index reuse, append, reuse plus append, empty to one;
  - a change to each single non-balance field;
  - exact byte layout;
  - the baseline is a value copy;
  - shrink is an error;
  - about 20 rejected rows, each with no mutation;
- both legacy queue decoders, before and after erigontech#25205, fail on a new row
without changing the list.
- **Reader:** exact fork slot, missed fork slot, dump plus change plus
append, a processed-block hole at the target and between the dump and
the target, a missed slot, a pre-fork slot, and a legacy row (sentinel,
with table and slot in the error).
- **Antiquary:**
  - backoff on the sentinel;
  - collector → mdbx → reader round trip;
  - the hook across the Fulu → Gloas fork;
- a Gloas genesis state at a dump slot (0) and at a slot that is not a
dump slot (32): the genesis row is the 26-byte no-change row, and both
the genesis state and the next block's change read back;
- a seam test with real 1000-slot snapshot files, pruning, and reads
that cross the file boundary and the snapshot/DB boundary, compared with
the lists actually written.
- **Benchmark:** `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 `-race` on `cl/antiquary`
- `make lint`, two runs, 0 issues
- `make erigon integration`
- Dual review until converged: two review rounds, a fix-delta review and
a fresh adversarial pass. The genesis-row commit had its own dual
review, with no blocker.

## Follow-ups (not in this PR)

- Gloas required-queue history is unreadable when every slot from the
fork through the next dump slot is missed: no Gloas dump is written at
that slot. This is pre-existing and affects all three Gloas queues.
- `LatestExecutionPayloadBidTable` stores a redundant full bid per slot.
It can be changed after the Hoodi fork.
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 Glamsterdam https://eips.ethereum.org/EIPS/eip-7773 type:bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cl/antiquary: Gloas builder diffs lose changes in historical states

3 participants