Bound `ChainState` partition growth by archiving pre-checkpoint history to object storage
Problem
Each microchain's ChainStateView is persisted as a single storage partition (RootKey::ChainState(chain_id); on ScyllaDB this is one partition of kv.table_linera, PRIMARY KEY (root_key, k)). Every view leaf (each MapView entry, LogView element, collection sub-entry) is one row in that partition, and several protocol-maintained sub-structures grow without bound and are never pruned:
received_log: LogView<ChainAndHeight>(linera-chain/src/chain.rs) — +1 row per received cross-chain message bundle, append-only.block_hashes: CustomMapView<BlockHeight, CryptoHash>(post-#6163; previouslyconfirmed_log) — +1 row per block, insert-only.
On busy chains this drives partitions past ScyllaDB's large-partition warning thresholds (compaction_rows_count_warning_threshold = 100_000 rows; compaction_large_partition_warning_threshold_mb = 1000 — both defaults on our 6.2.3 clusters, no overrides in scylla-config), causing sustained "Writing large partition" warnings, ~13x LCS write amplification on repeatedly-rewritten partitions, and degraded cache/compaction behavior. Note (ops): both violations — bytes or rows — emit the same Writing large partition …: (N bytes) log line (db/large_data_handler.cc, try_record prints an empty key field for partitions), so a 30 MB warning can be a >100k-row violation; the offending partition key is only recorded in system.large_partitions (30-day TTL).
ChainState is the largest offender but not the only partition type with unbounded per-partition growth. Three sibling per-chain partitions grow one row per block/event and are never deleted anywhere in the storage layer (written in write_confirmed_block / add_event, linera-storage/src/db_storage.rs):
RootKey::BlockByHeight(chain_id)— height→hash certificate index (#5233): +1 row per block, the same growth rate asblock_hashesabove. Pruningblock_hashesinsideChainState(#6599) does not touch it, so a busy chain keeps tripping the row-count threshold on this root key even after #6599.RootKey::Event(chain_id)— event payloads: +1 row per published event. Checkpoint summarization (#6493) removes the read guarantee below the stream floor, but no code deletes the rows.RootKey::EventBlockHeight(chain_id)— event→height index: +1 row per published event.
Unlike block_hashes' big-endian keys, these are BCS little-endian (to_height_key / to_event_key), so key order ≠ height order and range deletion needs key enumeration or an encoding change (same caveat as received_log below).
Measured data (production, testnet_conway)
Per-structure row counts for one large ChainState partition (a retired prediction-market chain), measured 2026-06-09/10 directly on a validator's ScyllaDB via single-partition clustering-range COUNT(*) (validator image = testnet_conway commit 84adeb0, whose view key layout is 0x01 + u32-LE field index per level):
| structure | rows | share | needed after a checkpoint at tip? |
|---|---|---|---|
received_log |
8,332,095 | 55% | no |
application KV state (execution_state.users) |
4,336,259 | 29% | yes (live state, app-owned) |
confirmed_log (= block_hashes on main) |
2,402,433 | 16% | no |
| system execution state + misc | ~30 | ~0% | yes |
| total | ~15.07M |
So ~71% of the partition (~10.7M rows) is protocol history that no node needs once a checkpoint exists above it. In a 24h sample, warning volume per GKE validator ranged from ~100 events (one validator with few but very large partitions) to ~22k events; on the OVH validator single partitions have reached 2–3 GB, crossing the byte-size threshold as well. (The application-owned 29% is tracked separately as an application data-model concern; it is out of scope here.)
Why checkpoints make this tractable
Checkpoint bootstrap already removes the need for pre-checkpoint history during sync:
bootstrap_chain_from_checkpoint(linera-core/src/client/mod.rs) lets a node reach the producer's exact state hash by downloading only the checkpoint certificate, itsCheckpointExecutionStateblob(s), and the chain's referenced blobs (OracleResponse::Checkpoint, #6364). Verified end-to-end bytest_checkpoint_bootstrapacross memory / storage-service / RocksDB.- #6399 (merged) lifted the no-messages restriction, and #6493 (merged) lifted the event restriction for user streams: at each checkpoint they are summarized (the app emits an absolute-state summary event;
previous_event_blocksis cleared) and pre-checkpoint events lose their availability guarantee. Only system event streams remain blocking: a chain that published or consumed system events (e.g. the admin chain's epoch streams) cannot checkpoint (prepare_checkpoint/check_checkpoint_preconditionsreject on system streams).
Because a node syncing past a checkpoint at height H never reads pre-H received_log / block_hashes entries, those rows can be archived to object storage (GCS/S3) and deleted from primary storage, keeping each ChainState partition bounded by (current execution state + history since the last checkpoint) — regardless of chain age. Restores from the archive should be rare and may be slow; the application/operator layer owns the archive, not the database.
Proposal (sketch — needs discussion)
- Auto-checkpointing policy: checkpoint each chain periodically (e.g. every N months), skipping chains with no activity since their last checkpoint. The most recent blocks always stay in primary storage; only history below the latest checkpoint is ever archived.
- Range-prune the historical sub-structures below
H(selective row deletion inside theChainStatepartition — the partition itself obviously stays, it holds the live state):block_hashes[height < H]: keys are big-endian heights, so a range delete is expressible today viaCustomMapView::removeper height or a bounded scan.received_log[index < N](N = first index at height ≥ H): requires a newLogViewprefix/range-delete primitive — todayLogViewonly haspush/clear(all-or-nothing), and its BCS-u32 little-endian index keys are not lexicographically range-scannable. Thestored_count/front bookkeeping must be updated accordingly.- the sibling per-chain partitions (see Problem above):
BlockByHeight[height < H]minus the checkpoint's vouched-for blocks, andEvent/EventBlockHeightbelow each stream's summarization floor — justified by the same checkpoint argument.
- Archive before delete: export the pruned rows (and optionally the corresponding pre-
Hblock/certificate/event partitions, which are separateroot_keys and cleaner to handle) to object storage in a durable format. - Keep durable: the checkpoint certificate at
Hand itsCheckpointExecutionStateblobs (mirror them to the archive bucket as well), and all blobs referenced byused_blobs— blobs are never dropped under this proposal.
Open questions
- Bootstrap authoritativeness:
latest_checkpoint_heightis self-advertised, and clients silently fall back to full-history sync when a checkpoint cert can't be served. Deleting history requires a guarantee that every (re)joining node can bootstrap from a durable checkpoint. What makes checkpoint availability authoritative? - System-event preconditions: user event streams are handled since #6493 (summarize + drop guarantee), but chains that published or consumed system events still cannot checkpoint — notably the admin chain never can. Does auto-checkpointing need an equivalent of summarization for system streams, or an explicit carve-out for these chains?
- Where the archival/GC job runs (per-validator vs coordinated), the archive format, and how a restore is triggered/served.
- Should the redundant height→hash duplication between
block_hashes(insideChainState) and the separateRootKey::BlockByHeight(chain_id)index be collapsed as part of this work? - Checkpoint cost:
dump_contentre-reads and re-writes the entire execution state; for chains with millions of live app-KV rows this is significant and untested at that scale. Does auto-checkpointing need chunked/incremental dumping?
Source: linera-io/linera-protocol