Skip to content

feat(core): live/historic storage split with per-epoch history buckets - #12144

Closed
muXxer wants to merge 12 commits into
developfrom
feat/historic-object-store
Closed

feat(core): live/historic storage split with per-epoch history buckets#12144
muXxer wants to merge 12 commits into
developfrom
feat/historic-object-store

Conversation

@muXxer

@muXxer muXxer commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Description of change

Splits node storage into a hot live set and per-epoch historic buckets, making pruning a constant-time bucket drop instead of per-key deletes plus compaction churn, and letting RPC fullnodes serve long history (target: ~100 epochs) without the hot tables paying for it.

When the historic-store pruning config is enabled (fullnode-only, off by default), the pruner relocates instead of deletes:

  • Superseded object versions move out of the perpetual objects table into per-epoch column families of a new always-open history RocksDB, bucketed by supersession epoch. The live table becomes heads-only (including Deleted/Wrapped tombstone heads, which every latest-version read depends on); tombstone heads are recorded in a per-bucket expiry list and point-deleted from the live table only when the bucket expires.
  • Checkpoint-keyed history (transactions, effects, executed-effects links, events, checkpoint contents and certified summaries) moves into the same epoch buckets, bucketed by the epoch of their checkpoint.

Expiring an epoch of history is a drop_cf per family — RocksDB unlinks the column-family SSTs outright, with no tombstones and no compaction pass. Crash consistency follows one contract everywhere: write history → flush (durability barrier) → delete source rows + advance watermark in one atomic batch; replay from the watermark is idempotent.

Reads: consensus and execution paths never touch history — a live-table miss there remains a loud failure. Old data is served through fallbacks that trigger only after a live miss: gRPC exact-version object lookups (GrpcReadStore), and gRPC + state-sync reads of old transactions/effects/events/checkpoints (RocksDbStore), with the advertised lowest_available_checkpoint extending back to the earliest relocated checkpoint. DB checkpoints include the history DB (snapshotted last — source-first ordering closes the torn-window between the two stores), so restored nodes keep serving their history; formal snapshots intentionally remain live-set-only.

Supporting changes: typed-store gains runtime Database::create_cf (with cf_names kept in sync for flush_all); the compaction-filter pruner is mutually exclusive with the historic store (enforced before the perpetual DB opens); nodes that previously ran with objects pruning disabled get a one-time watermark fast-forward past already-pruned checkpoint data.

Layout rationale: one DB with per-epoch column families (rather than one DB per epoch) shares a single block cache and file-descriptor budget, and RocksDB's native per-SST bloom filters answer the epoch-routing problem — exact-key lookups probe buckets newest-to-oldest, where a miss in a sealed, compacted bucket costs ~µs from in-memory filters.

Follow-ups tracked in plans/ on this branch: e2e simtests with the feature enabled and the benchmark matrix (which gate enabling this on production fullnodes), plus a behavior-preserving PrunerContext refactor of the pruner's parameter threading.

Links to any relevant issues

How the change has been tested

  • Basic tests (linting, compilation, formatting, unit/integration tests)
  • Patch-specific tests (correctness, functionality coverage)
  • I have added tests that prove my fix is effective or that my feature works
  • I have checked that new and existing unit tests pass locally with my changes

Details: cargo ci-clippy (workspace, all targets, -D warnings), cargo +nightly fmt, and IOTA_SKIP_SIMTESTS=1 cargo nextest run -p typed-store -p iota-core --lib (751 passed) run locally. ~25 new tests cover: heads-only invariant with tombstones as live heads, supersession-epoch bucketing, crash-replay idempotence for both object and checkpoint relocation, retention expiry with unwrap-resurrected lineages, live-object-set invariance under relocation and expiry, legacy V1-row migration at read, bucket rediscovery/seal state across restarts, availability-watermark tracking, DB-checkpoint copies serving all families, and the source-first snapshot-ordering torn-window test. Not yet run: e2e simtests with the feature enabled and performance benchmarks (see plans/historic-store-testing.md); the feature is off by default, so existing e2e coverage exercises the unchanged path.

Release Notes

  • Protocol:
  • Nodes (Validators and Full nodes): Adds an opt-in historic-store pruning mode for RPC fullnodes: the pruner relocates superseded object versions and checkpoint-keyed history into per-epoch buckets instead of deleting them, keeping the hot tables small while serving configurable epochs of history; expired epochs are dropped in constant time. Off by default; validators are unaffected (the setting is ignored with a warning). Incompatible with enable-compaction-filter. DB checkpoints of nodes with the feature enabled include the history DB.
  • Indexer:
  • JSON-RPC:
  • GraphQL:
  • gRPC: On fullnodes with the historic store enabled, exact-version GetObjects and transaction/effects/events/checkpoint lookups keep working for relocated (pruned) data, and the advertised lowest available checkpoint extends back to the earliest retained history instead of the pruning watermark.
  • CLI:
  • Rust SDK:

@iota-ci iota-ci added core-protocol node Issues related to the Core Node team labels Jul 3, 2026
@muXxer
muXxer force-pushed the feat/historic-object-store branch 2 times, most recently from 03e7b1a to 2514140 Compare July 11, 2026 14:12
@muXxer
muXxer marked this pull request as ready for review July 11, 2026 14:12
@muXxer
muXxer requested review from a team as code owners July 11, 2026 14:12
@muXxer
muXxer marked this pull request as draft July 11, 2026 16:51
muXxer added a commit that referenced this pull request Jul 29, 2026
Following the per-epoch history buckets of #12144: the 13 history tables
(transaction and event indexes) move from static column families into
one shared column family per epoch, created at runtime when an epoch's
first checkpoint is indexed. Within a bucket the tables are TaggedDBMaps, separated by a tag byte prefixed to every key, so one family per
epoch instead of thirteen keeps the column-family and SST-file counts an
order of magnitude lower across a ~100-epoch horizon, and makes a
bucket's existence atomic.

Transactions are numbered by network order and epochs partition that
order contiguously, so each bucket is a disjoint, epoch-ordered segment
of every table: queries chain per-bucket scans in epoch order (cursors
move into the scan bounds to compose across buckets), digest lookups
probe buckets newest first through their bloom filters, and the
crash-recovery replay check only consults the checkpoint's own epoch
bucket.

Pruning becomes one constant-time column-family drop per expired epoch,
replacing the compaction-filter machinery wholesale: the per-table
filter configurations, their metrics, the pruner watermark, and the
time-to-sequence cut are deleted. num_epochs_to_retain_for_indexes now
means exactly that. The newest N bucket epochs are kept.

On-disk column-family names are the ground truth for which buckets
exist; opening rediscovers them and passes them at open with tuned
options sharing one block cache.
muXxer added a commit that referenced this pull request Aug 7, 2026
Following the per-epoch history buckets of #12144: the 13 history tables
(transaction and event indexes) move from static column families into
one shared column family per epoch, created at runtime when an epoch's
first checkpoint is indexed. Within a bucket the tables are TaggedDBMaps, separated by a tag byte prefixed to every key, so one family per
epoch instead of thirteen keeps the column-family and SST-file counts an
order of magnitude lower across a ~100-epoch horizon, and makes a
bucket's existence atomic.

Transactions are numbered by network order and epochs partition that
order contiguously, so each bucket is a disjoint, epoch-ordered segment
of every table: queries chain per-bucket scans in epoch order (cursors
move into the scan bounds to compose across buckets), digest lookups
probe buckets newest first through their bloom filters, and the
crash-recovery replay check only consults the checkpoint's own epoch
bucket.

Pruning becomes one constant-time column-family drop per expired epoch,
replacing the compaction-filter machinery wholesale: the per-table
filter configurations, their metrics, the pruner watermark, and the
time-to-sequence cut are deleted. num_epochs_to_retain_for_indexes now
means exactly that. The newest N bucket epochs are kept.

On-disk column-family names are the ground truth for which buckets
exist; opening rediscovers them and passes them at open with tuned
options sharing one block cache.
muXxer added a commit that referenced this pull request Aug 7, 2026
Following the per-epoch history buckets of #12144: the 13 history tables
(transaction and event indexes) move from static column families into
one shared column family per epoch, created at runtime when an epoch's
first checkpoint is indexed. Within a bucket the tables are TaggedDBMaps, separated by a tag byte prefixed to every key, so one family per
epoch instead of thirteen keeps the column-family and SST-file counts an
order of magnitude lower across a ~100-epoch horizon, and makes a
bucket's existence atomic.

Transactions are numbered by network order and epochs partition that
order contiguously, so each bucket is a disjoint, epoch-ordered segment
of every table: queries chain per-bucket scans in epoch order (cursors
move into the scan bounds to compose across buckets), digest lookups
probe buckets newest first through their bloom filters, and the
crash-recovery replay check only consults the checkpoint's own epoch
bucket.

Pruning becomes one constant-time column-family drop per expired epoch,
replacing the compaction-filter machinery wholesale: the per-table
filter configurations, their metrics, the pruner watermark, and the
time-to-sequence cut are deleted. num_epochs_to_retain_for_indexes now
means exactly that. The newest N bucket epochs are kept.

On-disk column-family names are the ground truth for which buckets
exist; opening rediscovers them and passes them at open with tuned
options sharing one block cache.
muXxer added a commit that referenced this pull request Aug 7, 2026
Following the per-epoch history buckets of #12144: the 13 history tables
(transaction and event indexes) move from static column families into
one shared column family per epoch, created at runtime when an epoch's
first checkpoint is indexed. Within a bucket the tables are TaggedDBMaps, separated by a tag byte prefixed to every key, so one family per
epoch instead of thirteen keeps the column-family and SST-file counts an
order of magnitude lower across a ~100-epoch horizon, and makes a
bucket's existence atomic.

Transactions are numbered by network order and epochs partition that
order contiguously, so each bucket is a disjoint, epoch-ordered segment
of every table: queries chain per-bucket scans in epoch order (cursors
move into the scan bounds to compose across buckets), digest lookups
probe buckets newest first through their bloom filters, and the
crash-recovery replay check only consults the checkpoint's own epoch
bucket.

Pruning becomes one constant-time column-family drop per expired epoch,
replacing the compaction-filter machinery wholesale: the per-table
filter configurations, their metrics, the pruner watermark, and the
time-to-sequence cut are deleted. num_epochs_to_retain_for_indexes now
means exactly that. The newest N bucket epochs are kept.

On-disk column-family names are the ground truth for which buckets
exist; opening rediscovers them and passes them at open with tuned
options sharing one block cache.
muXxer added a commit that referenced this pull request Aug 10, 2026
Following the per-epoch history buckets of #12144: the 13 history tables
(transaction and event indexes) move from static column families into
one shared column family per epoch, created at runtime when an epoch's
first checkpoint is indexed. Within a bucket the tables are TaggedDBMaps, separated by a tag byte prefixed to every key, so one family per
epoch instead of thirteen keeps the column-family and SST-file counts an
order of magnitude lower across a ~100-epoch horizon, and makes a
bucket's existence atomic.

Transactions are numbered by network order and epochs partition that
order contiguously, so each bucket is a disjoint, epoch-ordered segment
of every table: queries chain per-bucket scans in epoch order (cursors
move into the scan bounds to compose across buckets), digest lookups
probe buckets newest first through their bloom filters, and the
crash-recovery replay check only consults the checkpoint's own epoch
bucket.

Pruning becomes one constant-time column-family drop per expired epoch,
replacing the compaction-filter machinery wholesale: the per-table
filter configurations, their metrics, the pruner watermark, and the
time-to-sequence cut are deleted. num_epochs_to_retain_for_indexes now
means exactly that. The newest N bucket epochs are kept.

On-disk column-family names are the ground truth for which buckets
exist; opening rediscovers them and passes them at open with tuned
options sharing one block cache.
muXxer added a commit that referenced this pull request Aug 10, 2026
Following the per-epoch history buckets of #12144: the 13 history tables
(transaction and event indexes) move from static column families into
one shared column family per epoch, created at runtime when an epoch's
first checkpoint is indexed. Within a bucket the tables are TaggedDBMaps, separated by a tag byte prefixed to every key, so one family per
epoch instead of thirteen keeps the column-family and SST-file counts an
order of magnitude lower across a ~100-epoch horizon, and makes a
bucket's existence atomic.

Transactions are numbered by network order and epochs partition that
order contiguously, so each bucket is a disjoint, epoch-ordered segment
of every table: queries chain per-bucket scans in epoch order (cursors
move into the scan bounds to compose across buckets), digest lookups
probe buckets newest first through their bloom filters, and the
crash-recovery replay check only consults the checkpoint's own epoch
bucket.

Pruning becomes one constant-time column-family drop per expired epoch,
replacing the compaction-filter machinery wholesale: the per-table
filter configurations, their metrics, the pruner watermark, and the
time-to-sequence cut are deleted. num_epochs_to_retain_for_indexes now
means exactly that. The newest N bucket epochs are kept.

On-disk column-family names are the ground truth for which buckets
exist; opening rediscovers them and passes them at open with tuned
options sharing one block cache.
muXxer added a commit that referenced this pull request Aug 11, 2026
Following the per-epoch history buckets of #12144: the 13 history tables
(transaction and event indexes) move from static column families into
one shared column family per epoch, created at runtime when an epoch's
first checkpoint is indexed. Within a bucket the tables are TaggedDBMaps, separated by a tag byte prefixed to every key, so one family per
epoch instead of thirteen keeps the column-family and SST-file counts an
order of magnitude lower across a ~100-epoch horizon, and makes a
bucket's existence atomic.

Transactions are numbered by network order and epochs partition that
order contiguously, so each bucket is a disjoint, epoch-ordered segment
of every table: queries chain per-bucket scans in epoch order (cursors
move into the scan bounds to compose across buckets), digest lookups
probe buckets newest first through their bloom filters, and the
crash-recovery replay check only consults the checkpoint's own epoch
bucket.

Pruning becomes one constant-time column-family drop per expired epoch,
replacing the compaction-filter machinery wholesale: the per-table
filter configurations, their metrics, the pruner watermark, and the
time-to-sequence cut are deleted. num_epochs_to_retain_for_indexes now
means exactly that. The newest N bucket epochs are kept.

On-disk column-family names are the ground truth for which buckets
exist; opening rediscovers them and passes them at open with tuned
options sharing one block cache.
@muXxer
muXxer force-pushed the feat/historic-object-store branch from 2514140 to 5284b44 Compare August 17, 2026 15:55
muXxer added 7 commits August 17, 2026 17:55
Execution-driven pruning (#12186) blocked the checkpoint executor while
pruning had fallen more than an hour of chain time behind. On upgrade,
many real nodes start with a larger backlog than that (downtime,
retention changes, catch-up sync, an old ticker-based pruner that fell
behind), and since the pruner only publishes progress after a full
drain, such nodes would stall execution for the whole first drain
instead of being throttled.

Pruning is now best-effort background work that never gates execution;
a backlog grows the database temporarily and is surfaced instead of
prevented:

- `pruning_chain_time_lag_ms` gauge: chain time between the executed
  watermark and the target of the pruner's last completed drain, plus a
  rate-limited warning above the old one-hour threshold.
- `last_pruned_checkpoint_timestamp_ms` /
  `last_pruned_effects_checkpoint_timestamp_ms` gauges, published per
  pruned batch so dashboards show progress during long drains.

The nudge channel and the drain loop are unchanged.
…pruning

Every node now relocates superseded object versions and pruned
checkpoint data into historic epoch buckets — there is no enable flag,
no fullnode-only gating, and no alternative pruning implementation:

- Config collapses to a single `historic-epochs-to-retain` knob
  (default 2, roughly today's default disk profile; RPC operators raise
  it). `num-epochs-to-retain`, `enable-compaction-filter` and the
  `historic-store` section are gone; unknown keys in existing config
  files are ignored.
- The compaction-filter pruning mode (`ObjectsCompactionFilter`,
  `AuthorityPrunerTables`, the `pruner` database) and the range-delete
  branch are deleted; relocation is the only implementation.
- The objects walker drains to the executed watermark with no retention
  window: relocation is not deletion, so nothing needs protecting. For
  data written by this version it finds nothing (commit-time relocation
  already moved it) — its work is pre-existing databases and capture
  misses.
- `HistoricStore` is constructed unconditionally (node, tools, test
  builder), `Option<Arc<HistoricStore>>` becomes `Arc<HistoricStore>`
  throughout, and superseded pre-images are always captured in
  transaction outputs. The whole existing test suite now runs with
  relocation active.
- DB-checkpoint upload compacts only (nothing left to prune in a
  snapshot of a continuously pruned source).
Nodes upgrading to commit-time relocation start with superseded
versions already in the live objects table. A crash-resumable migration
drains them once and remembers completion:

- A singleton `historic_migration` row in the perpetual store tracks
  progress (sweeping -> sweep complete -> complete). Databases created
  at genesis by this version start out complete; only upgraded
  databases migrate.
- The checkpoint walker (the relocation backstop) is the migration
  core: it drains backlog from the objects watermark and now reports
  how many superseded versions it actually found. The migration is
  marked complete once a drain reaches the executed watermark having
  found nothing, with the legacy sweep finished.
- The legacy sweep handles rows the walker can never reach — databases
  whose checkpoint data below the objects watermark was already pruned
  before the upgrade. It iterates the live table in bounded slices
  (cursor persisted atomically with each slice's moves), relocating
  non-heads into the current epoch's bucket and recording tombstone
  heads in its expiry list. It keeps slicing even without execution
  progress and retires permanently once done.
- Relocation into a bucket already past the retention horizon deletes
  outright instead of copying (equivalent to relocating and immediately
  dropping the bucket), so catching up through deep backlog does not
  write data just to drop it.
- After completion, any superseded version the walker still finds is a
  commit-time capture miss: `historic_capture_miss_total` is
  incremented and `debug_fatal!` fires. One quiet release in the wild
  proves capture exhaustive, after which the walker, the sweep, and the
  marker can be deleted. `historic_migration_state` exposes progress to
  operators.
Commit-time capture built `superseded` from `input_objects` only, so
mutations of runtime-loaded objects (dynamic fields) were never
captured — the walker backstop silently relocated every one of them,
which would have kept the capture-miss alarm firing forever. Found by
that alarm in the e2e suite: the randomness state update mutates a
child of the randomness object and panicked
`test_validator_tx_finalizer_fastpath_tx`.

`commit_transaction` now completes the capture for any superseded
version missing from the carried pre-images by reading it back through
the object cache — the transaction just read those objects, so the
lookups are memory-hot, and a version already relocated is absent from
the live view and needs no move. The walker also logs each version it
still finds, to make future alarm firings diagnosable.
…ead API

With relocation at commit time, superseded versions leave the live
objects table the moment their checkpoint commits, so the JSON-RPC
response assembly for freshly executed transactions (balance and object
changes read input pre-images by exact version) failed deterministically
with "could not find the referenced object". `read_object_at_version`
now falls back to the historic buckets after a live miss — a read-API
path only, unreachable from consensus and execution. This also makes
`iota_tryGetPastObject` serve versions within the historic retention
window.
…xpectations

The from-local-history epoch_info rebuild assembles old epoch-boundary
checkpoints, whose transaction, effects, events, and output objects have
usually been relocated into the historic buckets by the time a rebuild
runs (objects at commit time, the rest by the checkpoint pruner). Every
read in the assembly now falls back to the buckets after a live miss,
so `Missing` again only means the data aged past historic retention.

`object_pruning_test` asserted delete-mode semantics; under relocation
the live table keeps each lineage's tombstone head (including a stale
`Wrapped` tombstone below a resurrected lineage, removed only by its
bucket's expiry) while everything below moved to the buckets.
… fallback

A catching-up devnet node crashed assembling `CheckpointData` for
checkpoint 0: the clock object's genesis version had already been
superseded by consensus prologues of later checkpoints and relocated
out of the live table before the lagging checkpoint-data stage read it
as a genesis output.

Sweep of every remaining exact-version read that serves responses:

- `load_checkpoint_data` output objects (the crash): store read now
  falls back to the historic buckets when the buffered outputs are
  gone (replay after restart, or a stage lagging behind later commits).
- `AuthorityState::get_transaction_{input,output}_objects` (fullnode
  execute-transaction responses, validator gRPC responses): a
  transaction's own commit relocates the versions it superseded, so
  responses assembled after the commit read through the buckets.
- The local transaction-KV serving reads (`get_object`,
  `multi_get_objects`).

Consensus and execution paths keep no fallback: a miss there stays a
loud bug. The remaining exact-version readers are pre-commit index
post-processing (inputs still live by construction), validator
object-info requests (past versions unavailable there today too), and
the best-effort forensic dump (tolerates missing rows).
@muXxer
muXxer force-pushed the feat/historic-object-store branch from 5284b44 to 34fc5b8 Compare August 17, 2026 15:55
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 17, 2026
@muXxer

muXxer commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

super-seeded by #12695, which uses the new TaggedDBMap

@muXxer muXxer closed this Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core-protocol documentation Improvements or additions to documentation node Issues related to the Core Node team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants