Skip to content

[r3.8] cl: fix Gloas historical state reads and storage formats - #25256

Merged
AskAlexSharov merged 6 commits into
release/3.8from
kewei/cherry-pick-gloas-history-to-release-3.8
Oct 23, 2026
Merged

AskAlexSharov merged 6 commits into
release/3.8from
kewei/cherry-pick-gloas-history-to-release-3.8

Conversation

@domiwei

@domiwei domiwei commented Oct 22, 2026

Copy link
Copy Markdown
Member

Cherry-pick of #24667, #25212, #25201, #25211, #25230 and #25231 to release/3.8, in merge order. All six commits apply without changes.

#25201 is included because #25211, #25230 and #25231 depend on it in cl/antiquary. #24667 is included because #25201's tests expect its BlocksAvailable semantics, and because PtcWindowEpoch adds a table whose files can end before the others.

Release requirements

Checked on this branch

  • go test for every package with changed Go files: pass.
  • make lint: 0 issues.
  • make erigon integration: builds.

0xShadowX and others added 6 commits October 23, 2026 05:27
…t past the shortest table (#24667)

`CaplinStateSnapshots.BlocksAvailable()` is `min(SegmentsMax(),
IndicesMax())`, and its callers treat the result as the last slot the
files cover (the state antiquary resumes at `BlocksAvailable()+1`, the
historical reader serves slots up to it). `SegmentsMax()` is inclusive
(`to - 1`, like the base snapshots), but the Caplin `IndicesMax()`
returned the smallest exclusive `to` over the tables. When all tables
end at the same slot the inclusive value wins the `min` and nothing
shows. When they end at different slots, `BlocksAvailable()` is one past
the shortest table: with `BlockRoot` up to 50,000 and
`PendingDepositsDump` up to 100,000 it reports 50,000, but `BlockRoot`
has no segment for slot 50,000.

`IndicesMax()` now returns `minTo - 1`, the same convention as
`BaseRoSnapshots` and as `SegmentsMax()`. Two existing tests asserted
the exclusive value and are updated:
`TestCaplinStateBlocksAvailableUsesLowestTable` (50,000 → 49,999) and
the `IndicesMax()` check in `TestCaplinStateBlocksAvailableStopsAtGap`,
which already expected `SegmentsMax()` and `BlocksAvailable()` to be
49,999 next to it.

Testing:
```
go test ./db/snapshotsync/ -run TestCaplinState -count=1
# main: TestCaplinStateBlocksAvailableIsCoveredByEveryTable fails with
#   "table BlockRoot has no segment for BlocksAvailable()=50000"
go test ./db/snapshotsync/... ./cl/antiquary/... ./cl/persistence/state/... -count=1
```

(cherry picked from commit c19cee3)
Fixes #25200

## Problem

`ReadHistoricalState` reads `builders`, `builder_pending_withdrawals`
and `payload_expected_withdrawals` from the dump at `slot - slot %
SlotsPerDump` plus the per-slot diffs. `ReadRequiredQueueSSZ` returned
`ErrMissingGloasData` whenever that dump was missing. The antiquary
writes these dumps only for a Gloas state, so every state between the
Gloas fork slot and the first Gloas dump slot could not be read. On
Sepolia these are slots 11296768–11297279. The same happens for an
aligned fork slot whose block is missed.

## Fix

The change is reader-only. There is no antiquary change and no
persistence-format change.

- `ReadHistoricalState` passes the Gloas fork slot to
`ReadRequiredQueueSSZ`.
- A missing dump at or before the fork slot is read as an empty list.
The antiquary does not initialize the Gloas queue writers from a
pre-Gloas state, so the first Gloas diff holds the full list.
- In that case the replay starts at the dump slot itself, so a diff
stored there is not skipped. When the dump is present, the replay still
starts at the slot after it.
- A missing dump after the fork slot still returns
`ErrMissingGloasData`.

## Tests

- `TestReadRequiredQueueSSZ_MissedForkSlotBlock`: aligned fork slot with
a missed block. There is no dump, and the full diff is at the next slot.
- `TestReadRequiredQueueSSZ_MissingDumpAtForkSlotWithBlock`: the dump at
an aligned fork slot is missing and the full diff is at the fork slot.
- `TestReadRequiredQueueSSZ_MissingDump`: adds a dump slot right after
an unaligned fork, which still returns an error.
- `TestReadHistoricalState_GloasQueuesBeforeFirstDump`: end-to-end read
with fork epoch 49, which is not a multiple of 48. The fork slot is 1568
and the pre-fork dump slot is 1536.
- `TestReadHistoricalState_GloasQueuesMissingDumpAfterFork`: end-to-end
reverse case. The dump slot is after the fork and the dumps are missing,
so the read returns `ErrMissingGloasData`.

**TDD:** each new test was written first and failed on the previous code
for the intended reason:
- `MissedForkSlotBlock`, `MissingDumpAtForkSlotWithBlock` and
`GloasQueuesBeforeFirstDump` failed with `ErrMissingGloasData` on the
origin/main reader.
- `MissingDumpAtForkSlotWithBlock` returned `[b]` instead of `[a, b]`
before the replay-start fix.
- The added `MissingDump` case expects an error, so it also passes on
origin/main. It guards the relaxed check against admitting a dump slot
after the fork.
- The two `ReadHistoricalState` tests fail under the mutations
`gloasForkSlot := r.cfg.GloasForkEpoch` and `gloasForkSlot :=
^uint64(0)`. Both mutations passed the suite before these tests existed.

## Validation

- `go test ./cl/persistence/state/historical_states_reader/
./cl/antiquary/ -count=1`
- `make lint`, two runs, 0 issues
- `make erigon integration`

## Follow-ups (not in this PR)

- Builders queue encoder: in-place updates are lost, and a duplicate
trailing entry breaks the truncate count (#25205).
- If every slot from the fork slot through the next dump slot is missed,
there is no Gloas dump there and reads still return
`ErrMissingGloasData` until the following dump. This needs 33+ missed
slots; on Sepolia it needs 513.
- `GloasForkEpoch * SlotsPerEpoch` overflows for a far-future fork
epoch. It is reachable only when the reader's config differs from the
config that wrote the DB, for example in capcli.
- There is no writer-to-reader test across the Gloas fork.
- The empty-base path cannot tell a lost first full diff from a missed
slot. This happens only with lost rows, not with data the antiquary
writes.

(cherry picked from commit 0ce9d76)
… epoch (#25201)

Part of #25120 (item 7).

## What and why

The Caplin archive stored the Gloas `ptc_window` (96 slots × 512
validator indices, 393,216 bytes raw) in `PtcWindowTable` for every
slot. The window changes only at epoch boundaries
(`process_ptc_window`), so 31 of every 32 rows were copies. On mainnet
this would cost about 0.74–1.2 GB per day.

This PR stores the window once per epoch in a new table and Caplin state
snapshot type, `PtcWindowEpoch`. The estimate is about 23 MB per day at
Sepolia size and about 37 MB per day at mainnet size, roughly 32× less.

A new type is used instead of changing the meaning of the existing
`PtcWindow` rows: old binaries would misread sparse rows in the old
type, and a minor file-version bump does not stop them, because Caplin
state snapshots accept minor bumps as content-only.

## Changes

- New `kv.PtcWindowEpochTable` and snapshot type `PtcWindowEpoch`
(`MinCaplinEnum+35`). The key is the epoch-start slot. The value is the
zstd-compressed SSZ of the exact window.
- The antiquary writes one row per Gloas epoch:
- in `addGenesisState`, which covers a fork at genesis and the anchor
state;
- at each Gloas epoch boundary, which also covers epochs without blocks;
- at the fork. If the first Gloas block is after the fork slot, the
state is advanced to the fork slot first.
- `ReadHistoricalState` reads the row at `RoundSlotToEpoch(slot)`. A
missing row returns `ErrMissingGloasData`. A row that does not decode,
or has the wrong SSZ length, is a hard error.
- `PtcWindowTable` is no longer written or read. Its table, snapshot
type and existing data stay, so old binaries keep working.
- Startup: if states were already processed past the fork but the
fork-epoch row is missing (in the DB and in snapshots), the antiquary
re-processes from the last pre-Gloas epoch start that has a block.
- Snapshot dump: a range with an empty epoch-start row at or after the
fork is not dumped yet, and is retried later.
- Availability: `IndicesMax` treats `PtcWindowEpoch` as covered up to
the Gloas fork slot (`GloasForkEpoch × SlotsPerEpoch`, saturated),
because its files are empty by definition before the fork. Without a
beacon config the strict rule stays.
- `seg publishable` requires `PtcWindowEpoch` files ending at the common
end of the other state types only when that end is past the fork slot;
otherwise present files get only the structural checks. If the beacon
config cannot be resolved, the files are always required. For a known
chain, it also checks that every epoch-start word at or after the fork
is non-empty and that each file has the expected word count. `seg
integrity --check=Publishable` runs the same check.
- A no-op migration, `create_ptc_window_epoch_table`, creates the new
table in existing databases, so tools that open chaindata in accede mode
do not fail. `DBSchemaVersion` is unchanged.

## Rollout

`PtcWindowEpoch` limits state-snapshot availability only at and after
the Gloas fork slot.

- Chains before the fork (mainnet and Gnosis, where it is not scheduled,
and Hoodi until 2026-10-26): Caplin state snapshots work as before,
without any `PtcWindowEpoch` files. Nodes that dump state snapshots
write the all-empty pre-fork files themselves, so publishing them is
optional.
- Chains past the fork (Sepolia today, Hoodi after its fork): until
`PtcWindowEpoch` files are published, state-snapshot availability ends
at the last pre-fork slot, and the antiquary processes the rest locally
from just before the fork. To keep full availability, publish the new
files before releasing:
1. Run the snapshot generator with this binary. It re-processes from the
fork and produces the post-fork range.
2. Publish the new files in the preverified lists. Keep the old
`PtcWindow` files, because older releases still need them.
  3. Then release the binary.

Effects:
- Existing archive nodes continue from their DB progress. A Sepolia node
that is past the fork re-processes from just before the fork once.
- Older binaries ignore the new type. On Sepolia, their state snapshots
end at the last `PtcWindow` file, and they antiquate the rest locally.
- Existing `PtcWindowTable` rows on upgraded nodes are not deleted (on
Sepolia, about 10 GB since the fork, estimated, not measured).
- Downgrading after running this binary leaves slots without old rows;
the old binary then re-antiquates them through its normal backoff.

## Tests

- `TestPtcWindowStoredOncePerEpoch`
(`cl/antiquary/ptc_window_epoch_test.go`) checks the exact row for every
epoch over six Gloas spec fixtures: a block exactly at the fork, a first
block later in the fork epoch, the only block in the epoch after the
fork, a fork at genesis, an anchor followed by two epochs without
blocks, and a 64-block chain. It also checks that `PtcWindowTable` stays
empty. The expected rows come from replaying the fixture blocks through
the state transition and taking the window of each Gloas state,
independently of the antiquary code.
- `TestPtcWindowEpochSnapshotRead`: the row is read from a snapshot
segment.
- `TestPtcWindowEpochRestart`: a restart in the middle of an epoch.
- `TestPtcWindowEpochUpgradeReplay`,
`TestPtcWindowEpochUpgradeReplayStartsBeforeFork`,
`TestPtcWindowEpochReconstructionStart`: upgrade from a DB without new
rows, for a fork at genesis and a later fork.
- Missing and corrupt rows: `gloas_roundtrip_test.go`.
- Dump guard: `db/snapshotsync/caplin_state_ptc_window_epoch_test.go`.
- Fork-aware availability:
`TestCaplinStateBlocksAvailableBeforeUnscheduledGloasFork`,
`TestCaplinStateBlocksAvailableAtGloasFork`,
`TestCaplinStateBlocksAvailableRequiresPtcWindowEpochFromGenesis`
(`db/snapshotsync/caplin_state_lifecycle_test.go`).
- Publishability and content check:
`cmd/utils/app/publishable_check_test.go` (including the fork-aware
cases before an unscheduled or future fork, after the fork, and at the
fork boundary), `db/integrity/caplin_state_integrity_test.go`.
- `TestGloasHistoricalStateRoots` (`empty_epoch_transition`) checks the
full state root across an epoch boundary. Multi-block and later-fork
fixtures cannot be used for a full-root check yet because of #25199 and
#25200, which exist on `main` already. In those fixtures, a
field-by-field diff shows that the PTC window matches.

The Gloas spec fixtures under `cl/antiquary/tests/test_data/gloas/` add
3.8 MB.

(cherry picked from commit 4bb4524)
… participation replay (#25211)

## Summary

`HistoricalStatesReader.ReadParticipations` rebuilds the Gloas
participation lists by replaying block attestations. The replay did not
apply the Gloas payload-match rule, and it dropped errors from the
attestation callback. A rebuilt Gloas state could have wrong
`TIMELY_HEAD` flags and a wrong state root, or a partial participation
list with a nil error.

Fixes #25199

## Payload-match rule (consensus-specs v1.7.0-beta.4)

`get_attestation_participation_flag_indices` now applies the Gloas rule
in the historical path:

- a same-slot attestation (`is_attestation_same_slot`) needs `data.index
== 0`;
- any other attestation gets `TIMELY_HEAD` only when `data.index ==
execution_payload_availability[parent_slot %
SLOTS_PER_HISTORICAL_ROOT]`.

`parent_slot` is `state.latest_block_header.slot` before
`process_block_header`, which is the slot of the block at
`block.ParentRoot`. The replay resolves it from the block index and
falls back to the genesis state's latest block header. The availability
bit is read from the stored post-state of the including block.
`process_parent_execution_payload` runs before `process_operations` and
nothing later in the block changes the bit, so the stored value is the
one the spec uses. Blocks without attestations skip the lookup; this
keeps the synthetic genesis block of a Gloas-at-genesis chain (zero
`ParentRoot`) a no-op. Pre-Gloas behaviour of the rule is unchanged.

Errors from the replay (randao mix, attesting indices, flag computation,
parent lookup, availability) are now returned.

## Genesis-boundary fixes

The fixture tests could not pass without three more fixes:

- `readHistoricalBlockRoot` returned an error for the root at the
genesis slot, so attestations that target epoch 0 were dropped. It now
reads the DB for every slot from the genesis slot on and uses the
genesis `block_roots` vector only for slots before it.
- In the genesis epoch, attestations were written to the previous epoch
participation. An attestation now goes to the current epoch list exactly
when its target is the epoch of the requested slot (`data.target.epoch
== get_current_epoch(state)`). This also changes epoch-0 output for
Altair through Fulu, to the value the spec state has.
- The antiquary wrote the active validator indices of the genesis epoch
only at the next epoch boundary. `addGenesisState` now writes them too.

## Tests and fixtures

- `TestReadHistoricalStateGloasParticipation` rebuilds two Gloas mainnet
chains through the antiquary and compares current/previous participation
and the block state roots with the live transition. The fixtures are
copied from consensus-spec-tests **v1.7.0-beta.2**
(`sanity/blocks/attestations_after_missed_slot_use_applied_parent_payload_availability`
and `sanity/blocks/attestation`); the beta.4 transition accepts them
with full validation.
- `TestReadHistoricalStateGloasGenesisBlockWithoutAttestations`:
synthetic Gloas genesis block with a zero parent root.
- `TestReadParticipationsReturnsBlockRootError`: error propagation.
- Unit tests: `TestReadHistoricalBlockRootAtGenesisSlot`,
`TestGloasSameSlotAttestationRejectsNonzeroIndex`,
`TestGloasSameSlotAttestationAfterGenesisSlot`,
`TestPayloadMatchAppliesFromGloas` (Fulu keeps `TIMELY_HEAD`, Gloas
drops it).

TDD note: `TestGloasSameSlotAttestationAfterGenesisSlot` and
`TestPayloadMatchAppliesFromGloas` were added in the second commit,
after the behaviour landed in the first one, to close coverage gaps
found in review. They were checked by mutation: disabling the `data.slot
!= 0` same-slot branch, or removing the Gloas version gate, makes them
fail. The genesis-block test was red before its fix.

## Validation

- `go test ./cl/persistence/state/... ./cl/antiquary/...`
- the pre-Gloas reader round-trips with `ERIGON_RUN_HEAVY_TESTS=1`
- `make lint` (clean, repeated)
- `make erigon integration`

## Follow-ups (not in this PR)

- `ReadRandaoMixBySlotAndIndex` has the same genesis off-by-one as
`readHistoricalBlockRoot` had: on chains with genesis slot 0 it uses the
genesis mix instead of the final epoch-0 mix for epoch-2 attestations,
so states in epochs 2 and 3 are rebuilt with wrong committees.
- The replay needs the `BlockRootToSlot` index and the availability rows
for Gloas blocks with attestations; while the snapshot index is rebuilt,
direct `ReadParticipations` callers (liveness, lighthouse) return an
error, and antiquary init does not back off on that untyped error.
- A missing `ActiveValidatorIndicies` row is still read as an empty
list.
- No test crosses the Fulu→Gloas fork or covers Heze; pre-Gloas reader
tests run only with `ERIGON_RUN_HEAVY_TESTS`.
- The Gloas predicate is not shared with the live path in
`cl/phase1/core/state`.
- Anchors at a slot other than 0 are only partly handled.
- `attestingIndicies` picks the attestation format from the attestation
slot's epoch, not from the including block's version.

(cherry picked from commit 10c5204)
…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.

(cherry picked from commit 21fc1c3)
…iff (#25231)

Stacked on #25230 (which is stacked on #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 #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` (#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, #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.
- #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 #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.

(cherry picked from commit 36e6501)
Copilot AI balanced review requested due to automatic review settings October 22, 2026 21:49

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.

@AskAlexSharov
AskAlexSharov added this pull request to the merge queue Oct 23, 2026
Merged via the queue into release/3.8 with commit bc166f3 Oct 23, 2026
135 checks passed
@AskAlexSharov
AskAlexSharov deleted the kewei/cherry-pick-gloas-history-to-release-3.8 branch October 23, 2026 02:42
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