erigontech / erigontech/erigon
Epic: caplin snapshots on the erigondb segment lifecycle
- Dominant language
- Go
- Stars
- 3.6k
- Forks
- 1.5k
- Avg merge
- 1d 16h
- Merged PRs (30d)
- 455
Description
Give caplin snapshots the segment lifecycle erigondb already runs for EL blocks and transactions — `BaseRoSnapshots`, `db/mvcc` retire reasons, `Merger` — instead of the three lifecycle copies caplin maintains today (beacon blocks, blob sidecars, 33 beacon-state tables). CL adopts the EL machinery as-is first; the shared core gets extracted into common code after, once we know what CL actually needs from it.
Tracker. Child PRs and issues get linked under the workstream they belong to.
## Scoreboard
Current state, not state at filing. `n/a` = the family has no cold tier for the capability to apply to.
| capability | EL blocks/txs | caplin blocks | blob sidecars | caplin state | data columns |
|---|---|---|---|---|---|
| dirty→visible model | `BaseRoSnapshots` | `BaseRoSnapshots` #22944 | shares caplin blocks | `BaseRoSnapshots` #23352 | none, afero dir |
| reader refcount / pinned views | yes | yes | yes | yes | n/a |
| drain-gated reclaim (mvcc reasons) | yes | yes | yes | yes | n/a |
| live-safe `RemoveOverlaps` | yes, wired into retire | capable, never called at runtime | same | capable #23374, CLI-only | n/a |
| merge tiers | 1k/10k/100k `Merger` | 10k, frozen at birth | none | 50k node / 10k capcli | n/a |
| range claims (`TryAcquireRange`) | yes | no | no | no | n/a |
| missed-index rebuild | parallel, claim-guarded | own serial rewrite | own | own | n/a |
| index salt | `GetIndexSalt` | 0 | 0 | 0 | n/a |
| hot prune after freeze | yes | slot distance | slot distance | yes #22396 | slot distance |
| freeze-gated prune floor | `CanDeleteTo(frozen)` | **none** | **none** | `ContiguousCoverageEnd(table)` | **no cold tier at all** |
| cold expiry (segments) | `blocksRetentionCutoff` | none | none | none | n/a |
| visibility guards (gap-truncate, version dedup) | yes | yes | yes | yes #23374 | n/a |
| version gating on open | 3 layers | none | none | none | n/a |
| seeder `Delete` on file removal | yes | seed only | seed only | seed only | n/a |
| integrity checks | rich | none | dump-time only | roots scan, 2 of 33 tables | none |
## 1. Blocks + blobs on `BaseRoSnapshots`
- [x] #22878 — separate core/caplin/bor snaptype enum ranges, panic on duplicate registration. `Txt` and `BeaconBlocks` were both enum 9, so a Txt segment would have been written as `…-beaconblocks.seg`.
- [x] #22266 — bound caplin archive blob-column backfill so it can't wedge on Fulu.
- [x] #22944 — pre-fixes plus `CaplinSnapshots` on `BaseRoSnapshots`. Removes the first lifecycle copy. Four accessors stay caplin-owned: `SegmentsMax` is dirty-backed, `FrozenBlobs` is exclusive-`To`, `OpenFolder`/`BuildMissingIndices` because caplin blocks share `dirs.Snap` with EL segments while the base scans its directory unfiltered; the base's `BuildMissedIndices` is shadowed to route through them, since Go embedding has no virtual dispatch.
- [x] #23077 — review follow-up to #22944, stacked on it: delete the now-dead `CloseSegmentsNotInList`; `SegmentsMax` back to O(1) via a cached dirty max instead of a locked btree walk on a per-block path; `FrozenBlobs` without the write lock it took through `releaseVisible` → `reclaimRetired`; per-entry cancellation in the torrent scan; name the entries the caplin preverified filter dropped. Left out and still unfiled: the lazy salt read, and closing the embedding shadow set structurally.
Review of #22944 also fixed a pre-existing defect in shared code, which affects EL blocks and bor/heimdall equally: `BaseRoSnapshots.Close` gated the fd close on the immediately-outgoing generation's refcount, but generations share `*DirtySegment` values, so a reader pinned to an older generation had its segments closed under it. `Close` now retires them into the generation chain and lets the drain-gated reclaim run. `openSegments` also deduplicates its input — a repeated name opened one segment's indexes from two goroutines.
## 2. State on `BaseRoSnapshots`
- [x] #22256 — remove overlapping state snapshots on retire, offline-only.
- [x] #22294 — stop freezing empty caplin block/state roots.
- [x] #22385 — repair permanently-truncated static validators table.
- [x] #22400 — closed, superseded by the upstream substrate below. Its invariants, lifecycle tests and Windows fd discipline carry forward.
- [x] #23352 — register the 33 state tables as `snaptype.Type` and embed the base. Removes the third lifecycle copy. The `ParseFileName` nil-`Type` blocker is closed by construction: the types are registered, so `AllTypedSegments`, `openSegments`/`HasType` and `GetGrouping()` all resolve. Salt stays 0, decision F. Also hoisted `Cfg.MergeLimit` out of the per-segment `IsFrozen` scan for caplin enums.
- [x] #23374 — overlap-removal guards, opt-in per collection so EL keeps its current behaviour: equal-range version supersession, and a veto that keeps a segment whose covering file has no index on disk. Plus `IndicesMax` off a single visible generation, `SegFileNames` off dirty segments so a partially-filled dump still gets seeded, and nil guards on `Close`/`RemoveOverlaps` re-declared because the base is embedded by pointer.
Remaining under this item:
- [ ] #23412 — call `RemoveOverlaps` from the node. **PR #23471** wires the caplin *state* collection only; blocks and blobs wait on the two defects below. Both caplin collections only reach it from `cmd/utils/app/snapshots_cmd.go`, with `onDelete == nil`, so a running node never removes an overlapping file and never tells the seeder it went. That is the wiring half of the capability #23374 finished.
- [ ] #23469 — `RemoveOverlaps` cannot unlink a caplin beacon-block overlap. `OpenFolder` scans via `SegmentsCaplin`, whose `NoGaps` drops every covered subset before it becomes a `DirtySegment`, and the physical unlink only walks `s.dirty`. The file is orphaned on disk while `onDelete` still drops its `.torrent`. Blocks the beacon-block half of #23412; blob sidecars are unaffected, they bypass the pre-filter.
- [ ] #23470 — `RemoveOverlaps` ends by deleting every `.tmp` in its directory. Safe today only because its one caller runs after `merger.Merge` on the same goroutine; a second collection sharing `dirs.Snap` and its own ticker can unlink an EL merge's in-flight compressor output. Blocks wiring `CaplinSnapshots`, which lives in `dirs.Snap`.
## 3. Merge tiers
Motivation, snapshotter side: #22398. The mainnet toml already ships 100k-wide beaconblocks files and one 10.5M-slot merged file per state table, and node code can reproduce neither.
- [ ] *not filed* — beaconblocks/blobsidecars 10k → 100k. Needs item 1. `Merger.FindMergeRanges` hardcodes `snaptype.Unknown` and routes past the caplin branch of `Cfg.MergeLimit`, so this needs a type parameter, not only a new limit. `Seedable`/`IsFrozen` compare against a single limit and need the `MergeSteps` treatment or surviving 10k files stop being seedable. `NewMerger` takes `*chain.Config`, caplin carries `*clparams.BeaconChainConfig`.
- [ ] *not filed* — state 10k → 100k → 1m. Needs item 2. Word-concat merge is enough (one word per numeric id), no re-dump. Flip the node base dump size 50k → 10k last; mixed sizes on disk must stay valid merger input.
## 4. Retention: three layers, one vocabulary
Every erigondb datum moves through the same three steps, and EL names all three. CL names none of them, and spreads the same decisions across seven flags that each touch two or three steps at once.
| layer | EL | CL today |
|---|---|---|
| **acquire** — how far back we fetch | `downloadFilteringApplies` / `blocksRetentionCutoff`, driven by `--prune.mode` | three archive booleans, each gating a different half: `ArchiveBlocks` gates the whole caplin download **and** P2P backfill depth, `ArchiveBlobs` the blobsidecar segments, `ArchiveStates` the `caplin/` segments (`stageloop.go:368`) |
| **promote** — hot → cold | `PruneAncientBlocks`, always on, floor `CanDeleteTo(head, frozen)` = `min((head/1000)*1000 − 1024, frozen+1)`. **Not a policy knob** | `--caplin.snapgen`, default **false**. The only promote switch, orthogonal to every retention flag |
| **expire** — what gets dropped | hot floor is the frozen clamp; cold floor is `--prune.mode` | hot floor is a constant with no frozen clamp; **cold floor does not exist** — no caplin segment is ever expired |
Consequences, all verified on main:
- **`full` — freeze *and* prune — is not reachable from any flag combination**, which is why the freeze-gate work under this item stalled. `--caplin.blobs-archive` sets acquire to *everything* and expire to *never* in one flag, so retention is off. Drop it for `--caplin.blobs-immediate-backfill` and retention comes back at 128,600 — but backfill now only reaches `head − MinSlotsForBlobsSidecarsRequest()` (`blob_downloader.go:226-230`) while `antiquateBlobs` floors its range at the Deneb fork boundary (`antiquary.go:471-472`), so `DumpBlobSidecarsRange` returns `blob sidecars not found for block N` (`caplin_snapshots.go:336`) and the whole range fails, forever. Either retention is off, or freezing wedges. #23224 is that wedge with a narrower gap.
- The hot floors are constants, not policy, and none is derived from the spec window: blocks 1,000,000 slots (`cleanup_and_pruning.go:17`), blobs 128,600 (`run.go:292`), columns 131,072 (flag default).
- `--caplin.blobs-archive` and `--caplin.blobs-no-pruning` are behaviourally identical for retention — both set `pruneBlobDistance = MaxUint64`. Two flags, one effect.
- `--caplin.blocks-archive` and `--caplin.blobs-archive` still ship the same usage string, "sets whether backfilling is enabled for caplin".
- `ArchiveBlocks` is the OR of all three (`flags.go:1883`), so `--caplin.blobs-archive` silently turns on block archival and only the states flag documents that it does.
- The network-aware column default is dead code — #23411. `cleanup_and_pruning.go:30-33` only fires when `ColumnKeepSlots == 0`, but the flag default is 131,072, and `MinEpochsForDataColumnSidecarsRequests` is 4096 with no per-network override. Gnosis/Chiado want 65,536 and get 131,072, and `--caplin.columns-keep-slots=0` reads as "keep nothing" while meaning "spec default".
- Neither blob flag reaches the column store — #23410. `NewDataColumnStore` is constructed with `pruneBlobDistance` (`run.go:417`), but `dataColumnStorageImpl.slotsKept` is never read; `Prune` takes the distance as a parameter and gets `ColumnKeepSlots` instead. Columns have no cold tier, so for an archive operator that is permanent loss at 131,072 slots.
- Under the engine API the first four flags are discarded with a warning each (`flags.go:1888-1896`).
**The target model is already in the tree, for one family.** #22396's `pruneFrozenStateTables` (`cl/antiquary/state_prune.go`) prunes each state table up to `min(ContiguousCoverageEnd(table), flushedThrough)` — a per-type freeze floor, budgeted per tick, resumable, with markers floored back on replay. Blocks, blobs and columns need that shape, not a new design.
Work items:
- [x] #22396 — prune the caplin state indexing DB after freezing to snapshots.
- [ ] *not filed* — name the three layers and give each family one setting per layer. Shape, mirroring EL's mode-plus-override but on CL's own flag: `--caplin.prune.mode = archive | full | minimal` setting defaults for all four families, with `--caplin.prune.{blocks,states,blobs,columns}` overriding one family with `archive` or a slot count. Today's flags map onto it without an unreachable state, `blobs-no-pruning` collapses into `prune.blobs=archive`, `blobs-immediate-backfill` becomes derived from the blob target rather than configured, and `states=archive ⇒ blocks=archive` stays an implication (the state antiquary replays blocks) but is stated as one instead of being an OR that mutates a different flag. Needs decision C.
- [ ] *not filed* — freeze-gate the hot floors. Clamp the beacon-block distance prune to `BlocksAvailable()` and the blob/column retention to `FrozenBlobs()` / the column equivalent, in the shape of `pruneFrozenStateTables`. Behaviour change: a family with no cold tier must fall back to the serving window rather than to "keep everything", or a stock node stops pruning entirely. Needs the layer split above, so that fallback is expressible.
- [ ] *not filed* — `--caplin.snapgen` default. Once the hot floor is the frozen floor, promote-off means keep-everything, so a mode that expires anything has to imply promote. Either fold snapgen into the mode or rename it `--caplin.snapshots` defaulting true, mirroring EL's `--snapshots`. Same decision as the freeze gate, not a separate one.
- [ ] *not filed* — cold expiry. No caplin segment is ever deleted by retention, so an archive-flagged node and a pruned node keep identical segment sets. `full` and `minimal` are meaningless until this exists. For state the cutoff is constrained: the read unit is a (dump, diff) pair, so a cutoff must land on a `SlotsPerDump = 1536` boundary and a dump must not outlive its diffs.
- [ ] *not filed* — prune the lookup tables after freeze. `BeaconBlockHeaders`, `CanonicalBlockRoots`, `BlockRootToSlot` and the state-root/parent-root/execution-number indices are kept forever because by-root segment reads go through them. Needs decision B. `PruneSignedHeaders` is dead and broken (seeks a 4-byte slot key into a root-keyed table) — delete it, don't wire it.
- [x] #23413 — collapse `BlobStore` and `dataColumnStorage`, shipped in #23451. Same object, ~45 lines copy-pasted, and both `Prune` implementations re-walk every 10,000-slot bucket from slot 0 once per slot. Self-contained, needs no vocabulary agreement. Its follow-up #23450 — `OpenCaplinDatabase` carrying an `ethClock` the collapse made dead — was fixed in the same PR.
- [ ] #23426 — per-slot leaf directory. The flat 10,000-slot bucket holds up to 90,000 blob files, or 1.28M column files on a supernode, and the per-object paths `Stat` inside it. Blocked on #23413, where it becomes one path function. Migration is an in-place rename walk, not a re-download: these files are never torrented (`db/downloader/` has no reference to either dir) and `snapshots reset` is scoped to `SnapDir`, so refill is p2p backfill only — and for `--caplin.blobs-archive` without `--caplin.snapgen` the hot store is the sole copy, which peers will not serve back.
- [ ] #23429 — **PR #23468**, approved and green. `RenameOldVersions` recursively walks `CaplinBlobs` and `CaplinColumnData` on every start (`dirs.go:279`, called from `flags.go:1624`) looking for a `v1-` prefix those filenames cannot carry. Order 514k dirents at the default custody, 16.5M on a supernode. Independent of the two above.
- [ ] #23433 — give the column store the index the blob store has. `GetSavedColumnIndex` answers "which columns do I hold" with 128 `Stat` calls on a hot path; `kv.BlockRootToDataColumnCount` is declared and registered but never read or written. Wire it as a root→bitmap. After #23413.
- [ ] #23432 — pruned blobs leave their `BlockRootToKzgCommitments` rows behind forever: `Prune` deletes files and never touches MDBX. Size, not correctness, but unbounded — and #23433 would inherit the same leak.
The freeze gate is also the ordering guarantee the two components lack today. #23224 is a Gnosis archive node wedged on a missing sidecar at slot 29403906. What produced that particular gap is unconfirmed, but retention can produce exactly it — the floor sat 2.8 days below the antiquation frontier — and `DumpBlobSidecarsRange` treats the hole as fatal, so the same range failed 328 times in 65 minutes, each attempt re-reading ~3900 beacon bodies. #23252 backs the retry off to 1, 2, 4 … 300 ticks so a wedged node is quiet and cheap; it repairs no gap and stops none forming. What a genuinely missing sidecar should do — backfill, record and continue, or fail permanently — is still open on #23224.
Independent of items 1-3, biggest DB-size win.
## 5. Distribution, versioning, integrity
- [x] #22295 — `CaplinStateRoots` integrity check for blank root snapshots.
- [ ] *not filed* — version gating on caplin open paths; `MustSupport` appears nowhere today.
- [ ] *not filed* — drop the string-prefix special cases now that the types are registered: sync name filters (`snapshotsync.go:507-520` still matches on `"beaconblocks"` / `"blobsidecars"` / `"caplin/"`), downloader seedable bypass, `ParseEnum` aliases. Rename the blob index off the legacy `blocksidecars` alias.
- [ ] *not filed* — integrity parity: beaconblocks read spot-check, slot-gap check, blob sidecar count and KZG check at rest, idx↔seg consistency.
Upstream substrate this rides on: #21397, #22246, #22365, #22661.
## Decisions
| | question | status |
|---|---|---|
| A | how state tables get typed | **resolved**, shipped in #23352: each is a `snaptype.Type`. `Merger` takes `[]snaptype.Type`, so the alternative meant generalizing a core shared with EL and bor |
| B | by-root reads from segments, so lookup tables become prunable | open, gates one box of item 4. Per table: a root→slot accessor covers forward lookups, but `StateRootToBlockRoot` is a reverse index serving `/states/{state_root}` and needs its own accessor or exclusion from the prune set |
| C | `--prune.mode` vs the seven caplin flags with hardcoded distances | **direction set**: state acquire / promote / expire separately, per family, in EL's mode-plus-override shape. Promote stops being a policy knob and the hot expire floor becomes the frozen floor, as it already is for state. **CL takes its own `--caplin.prune.mode`**, not EL's — CL and EL histories are genuinely independent, an archive EL with a minimal CL is a normal RPC node. Binding the two (deriving CL's default from `--prune.mode`) stays on the table as a later convenience, not a coupling. Still open: the mode names, and whether a no-cold-tier family falls back to the serving window or to keep-everything. **Now blocks the freeze gate**, which cannot be expressed until the layers are separate |
| D | trim the 20k-slot freeze safety margin | open, needs a reorg-safety call. Decide with the state merge tier |
| E | data-column (Fulu/PeerDAS) snapshots — no snaptype, no freeze path | open. Columns are the one family with no cold tier at all, so item 4's freeze gate has nothing to gate on for them and they fall back to the serving window. Real blockers to a cold tier: sliding window vs gap-free-from-genesis, pre-finality mutability, per-`(slot,root,index)` random access under DAS custody where peers hold different subsets, and a batch compressor vs the incremental `WriteStream` writer |
| F | index salt for caplin | **resolved: salt stays 0 for the whole epic**, revisit only once every item here has landed. Nothing in items 1-5 may introduce a caplin salt file or assign `CaplinSnapshots.Salt`. `salt-blocks.txt` is the EL blocks salt and caplin has no salt file of its own, so caplin indexes are built with a constant 0 — `CaplinSnapshots.Salt` has never been assigned on main, 3.4, 3.5 or 3.6. Measured on a mainnet node: a local caplin `.idx` carries `salt=0`, and 0 of 40 sampled local caplin indexes match their published hashes, so locally built indexes already do not reproduce the distributed set. Giving CL a real salt therefore buys nothing against the published files and costs node-to-node determinism; it needs its own salt file and a deliberate re-publish, not a borrow from EL |
## Out of scope
Rehosting the retire/reclaim core into `db/mvcc` proper. The `db/snapshotsync` ↔ `db/state` duplication is pre-existing EL debt; caplin adoption goes through `BaseRoSnapshots`, which already sits on the substrate. Revisit once CL is unified.
Contributor guide
Research direction
Start by reading the linked child work around BaseRoSnapshots, db/mvcc, Merger, and cl/antiquary/state_prune.go, then inspect the entry points named in caplin_snapshots.go, cleanup_and_pruning.go, run.go, flags.go, and stageloop.go. The epic is complete only when the caplin snapshot families share the intended lifecycle and retention model, but its remaining work includes unfiled design decisions and active child issues.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend, databases
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100