Skip to main content

sui_rpc_store/indexer/
pruner.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Background pruner for the historical column families.
5//!
6//! Pruning is a standalone [`Service`] rather than a framework
7//! pipeline: it does not consume checkpoints from ingestion, it reads
8//! the already-committed state and deletes data below a retention
9//! floor. The shape mirrors the validator's perpetual-store pruner
10//! (a periodic background task) more than the indexer framework's
11//! per-pipeline `prune` hook — the deletions are data-driven (we walk
12//! transaction effects to retract superseded object versions) and the
13//! floor is a single value shared across every historical CF.
14//!
15//! # What gets pruned
16//!
17//! - **Per-transaction CFs** (`transactions`, `effects`, `events`,
18//!   `tx_metadata_by_seq`) — range-deleted over the pruned `tx_seq`
19//!   range; the keys are contiguous big-endian `tx_seq`, so one range
20//!   tombstone per CF clears the chunk.
21//! - **Per-checkpoint CFs** (`checkpoint_summary`,
22//!   `checkpoint_contents`) — range-deleted over the pruned
23//!   checkpoint range.
24//! - **Digest reverse indexes** (`tx_seq_by_digest`,
25//!   `checkpoint_seq_by_digest`) — point-deleted; their keys are
26//!   digests, so we collect them from the data being pruned (tx
27//!   digests from each effects row, checkpoint digests from each
28//!   summary) before deleting.
29//! - **`objects` history** — point-deleted, effects-driven: each
30//!   pruned transaction's `modified_at_versions` (superseded input
31//!   versions) and `all_tombstones` (deleted / wrapped markers) are
32//!   the exact `(ObjectID, version)` rows that are now dead. The
33//!   latest live version is never an input to a pruned transaction,
34//!   so it — and the greatest `object_version_by_checkpoint` entry
35//!   that resolves to it — is preserved.
36//! - **`object_version_by_checkpoint`** — retracted in lockstep with
37//!   `objects` history: the same effects-driven walk records per-object
38//!   retractions, then each prune batch coalesces them per object to the
39//!   latest superseding checkpoint and point-deletes that object's
40//!   checkpoint-pinned entries below it (plus the tombstone entry itself
41//!   when the object was removed there). Point deletes rather than a range
42//!   delete because a hot object's successive retractions share the `id||0`
43//!   start and would nest into `O(K^2)` range-tombstone fragments (see
44//!   the `Retractions` collector). The retained set mirrors the `objects`
45//!   versions kept, so the index never points at a pruned version.
46//! - **Ledger-history bitmaps** (`transaction_bitmap`,
47//!   `event_bitmap`) — not deleted directly; advancing the
48//!   database-local pruning floor lets their compaction filters drop
49//!   fully-pruned buckets. Merge operands can require one covering
50//!   compaction to materialize and a later compaction to filter; the
51//!   forced catch-up pass and periodic compaction provide those sweeps.
52//!
53//! The live-set-bounded indexes (`object_by_owner`, `object_by_type`,
54//! `balance`, `package_versions`) and the tiny `epochs` CF are never
55//! pruned.
56//!
57//! # Floor, retention, and safety
58//!
59//! Retention is epoch-based: the `retention_epochs` most-recent
60//! epochs are retained, and the target floor is the start checkpoint
61//! of the oldest retained epoch. The floor is then clamped so it
62//! never advances past the oldest in-memory snapshot's checkpoint:
63//! point and range deletes are already invisible to a snapshot
64//! (RocksDB pins the data a live snapshot references), but the bitmap
65//! compaction filter physically removes buckets irrespective of
66//! snapshots, so the clamp keeps every live snapshot's advertised
67//! available range valid even under an aggressively small retention.
68//!
69//! Each tick advances the floor toward that target by at most
70//! `max_checkpoints_per_tick` checkpoints (in `max_chunk_checkpoints`
71//! atomic chunks), so a large backlog — for example when pruning is
72//! first enabled on an old database — drains across many ticks rather
73//! than one long blocking pass. The floor converges to the target
74//! over subsequent ticks.
75//!
76//! # Ordering and crash-safety
77//!
78//! Each chunk stages all of its deletes plus the new
79//! `PruningWatermarks` row into one atomic batch, commits, and only
80//! then advances the in-memory bitmap floor. Because the watermark
81//! row lives in the same batch as the deletes, a crash either loses
82//! the whole chunk (re-pruned next run) or commits it wholesale;
83//! there is no partial-delete-without-watermark state. Range and
84//! point deletes are idempotent, so a re-run is harmless.
85
86use std::collections::HashMap;
87use std::ops::Bound;
88use std::sync::Arc;
89
90use anyhow::Context as _;
91use prometheus::IntCounter;
92use prometheus::IntGauge;
93use prometheus::Registry;
94use prometheus::register_int_counter_with_registry;
95use prometheus::register_int_gauge_with_registry;
96use sui_consistent_store::Batch;
97use sui_consistent_store::Db;
98use sui_consistent_store::FrameworkSchema;
99use sui_consistent_store::PipelineTaskKey;
100use sui_indexer_alt_framework::service::Service;
101use sui_types::base_types::ObjectID;
102use sui_types::effects::TransactionEffects;
103use sui_types::effects::TransactionEffectsAPI;
104use sui_types::message_envelope::Message;
105use tokio::time::MissedTickBehavior;
106use tracing::debug;
107use tracing::info;
108use tracing::warn;
109
110use crate::RpcStoreSchema;
111use crate::config::PrunerConfig;
112use crate::indexer::Store;
113use crate::indexer::restore::HISTORY_COHORT;
114use crate::indexer::restore::LIVE_COHORT;
115use crate::schema::checkpoint_seq_by_digest;
116use crate::schema::event_bitmap;
117use crate::schema::object_version_by_checkpoint;
118use crate::schema::objects;
119use crate::schema::primitives::U64Be;
120use crate::schema::pruning_watermark;
121use crate::schema::pruning_watermark::Watermarks;
122use crate::schema::transaction_bitmap;
123use crate::schema::tx_seq_by_digest;
124
125/// Prometheus metrics for the pruner.
126pub struct PrunerMetrics {
127    /// Lowest still-available checkpoint sequence number — the
128    /// persisted checkpoint floor.
129    pub checkpoint_lo: IntGauge,
130    /// Lowest still-available transaction sequence number — the
131    /// persisted `tx_seq` floor.
132    pub tx_seq_lo: IntGauge,
133    /// Total pruning chunks committed.
134    pub chunks_committed: IntCounter,
135    /// Total superseded object versions and tombstones deleted.
136    pub objects_deleted: IntCounter,
137}
138
139impl PrunerMetrics {
140    pub fn new(prefix: Option<&str>, registry: &Registry) -> Arc<Self> {
141        let prefix = prefix.unwrap_or("rpc_store_pruner");
142        let name = |n| format!("{prefix}_{n}");
143
144        Arc::new(Self {
145            checkpoint_lo: register_int_gauge_with_registry!(
146                name("checkpoint_lo"),
147                "Lowest still-available checkpoint sequence number (pruning floor)",
148                registry,
149            )
150            .unwrap(),
151            tx_seq_lo: register_int_gauge_with_registry!(
152                name("tx_seq_lo"),
153                "Lowest still-available transaction sequence number (pruning floor)",
154                registry,
155            )
156            .unwrap(),
157            chunks_committed: register_int_counter_with_registry!(
158                name("chunks_committed"),
159                "Total pruning chunks committed",
160                registry,
161            )
162            .unwrap(),
163            objects_deleted: register_int_counter_with_registry!(
164                name("objects_deleted"),
165                "Total superseded object versions and tombstones deleted by the pruner",
166                registry,
167            )
168            .unwrap(),
169        })
170    }
171}
172
173/// Collects `object_version_by_checkpoint` retractions for one prune batch,
174/// coalescing every retraction for an object to a single entry.
175///
176/// Hot objects such as Clock and SuiSystemState can be superseded in every
177/// checkpoint, so one prune batch retracts the same object many times. The
178/// retraction point-deletes each checkpoint-pinned row below the superseding
179/// checkpoint by walking the object's prefix once (see
180/// [`retract_object_version_by_checkpoint`]); coalescing keeps a hot object's
181/// prefix from being walked once per supersession.
182///
183/// The retraction uses point deletes rather than one
184/// `delete_range [id||0, id||cp)` precisely because successive batches retract a
185/// hot object at an ever-greater `cp`, all sharing the `id||0` start, so the
186/// range tombstones nest and RocksDB's `FragmentedRangeTombstoneList` fragments
187/// `K` of them into `K^2 / 2` `(fragment, seqnum)` pairs -- which OOMed mainnet
188/// fullnodes during memtable flush and WAL recovery.
189///
190/// Coalescing keeps only the greatest checkpoint per object: its rows below that
191/// checkpoint are the union of every narrower retraction's rows, so one widest
192/// retraction subsumes them all. On an equal checkpoint, the `removed` flags are
193/// ORed: if any same-checkpoint retraction removed the object, the row at that
194/// checkpoint must be dropped.
195#[derive(Default)]
196struct Retractions(HashMap<ObjectID, (u64, bool)>);
197
198impl Retractions {
199    fn record(&mut self, id: ObjectID, cp: u64, removed: bool) {
200        self.0
201            .entry(id)
202            .and_modify(|(recorded_cp, recorded_removed)| {
203                if cp > *recorded_cp {
204                    *recorded_cp = cp;
205                    *recorded_removed = removed;
206                } else if cp == *recorded_cp {
207                    *recorded_removed |= removed;
208                }
209            })
210            .or_insert((cp, removed));
211    }
212
213    fn stage(self, batch: &mut Batch, schema: &RpcStoreSchema) -> anyhow::Result<()> {
214        for (id, (cp, removed)) in self.0 {
215            retract_object_version_by_checkpoint(batch, schema, id, cp, removed)?;
216        }
217        Ok(())
218    }
219}
220
221/// Start the background pruner as a [`Service`].
222///
223/// Errors if `config.retention_epochs` is `0` (which would prune the
224/// current epoch). The returned service runs an infinite tick loop;
225/// it is aborted on graceful shutdown (each chunk is atomic, so an
226/// abort leaves the database consistent).
227pub fn start_pruner(
228    store: Store,
229    config: PrunerConfig,
230    metrics: Arc<PrunerMetrics>,
231) -> anyhow::Result<Service> {
232    anyhow::ensure!(
233        config.retention_epochs >= 1,
234        "PrunerConfig::retention_epochs must be >= 1; 0 would prune the current epoch",
235    );
236    anyhow::ensure!(
237        config.max_checkpoints_per_tick >= 1,
238        "PrunerConfig::max_checkpoints_per_tick must be >= 1; 0 would never make progress",
239    );
240
241    let service = Service::new().spawn_aborting(async move {
242        let mut ticker = tokio::time::interval(config.interval());
243        ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
244
245        loop {
246            ticker.tick().await;
247
248            let store = store.clone();
249            let config = config.clone();
250            let metrics = metrics.clone();
251
252            // The pruner does blocking RocksDB iteration and writes;
253            // keep it off the async runtime threads.
254            let res = tokio::task::spawn_blocking(move || {
255                prune_once(store.db(), store.schema(), &config, &metrics)
256            })
257            .await;
258
259            match res {
260                Ok(Ok(())) => {}
261                Ok(Err(e)) => {
262                    warn!("rpc-store pruner pass failed (will retry next interval): {e:#}")
263                }
264                Err(e) => warn!("rpc-store pruner task join error: {e}"),
265            }
266        }
267    });
268
269    Ok(service)
270}
271
272/// Run a single pruning pass: recompute the target floor and advance
273/// the persisted floor toward it one chunk at a time.
274fn prune_once(
275    db: &Db,
276    schema: &RpcStoreSchema,
277    config: &PrunerConfig,
278    metrics: &PrunerMetrics,
279) -> anyhow::Result<()> {
280    let Some(current_epoch) = current_committed_epoch(db)? else {
281        debug!("rpc-store pruner: no committed watermark yet; nothing to prune");
282        return Ok(());
283    };
284
285    let Some(retention_lo) =
286        retention_checkpoint_floor(schema, current_epoch, config.retention_epochs)?
287    else {
288        debug!(
289            current_epoch,
290            "rpc-store pruner: retention floor not yet reached; nothing to prune"
291        );
292        return Ok(());
293    };
294
295    // Never advance the floor past the oldest live snapshot.
296    let target_lo = clamp_to_snapshot(retention_lo, db.snapshot_range().map(|r| *r.start()));
297
298    let mut cursor = schema.get_pruning_watermarks()?.unwrap_or_default();
299    if target_lo <= cursor.checkpoint_lo {
300        debug!(
301            target_lo,
302            current_lo = cursor.checkpoint_lo,
303            "rpc-store pruner: floor already at or beyond target"
304        );
305        return Ok(());
306    }
307
308    // Bound the work done this tick: advance the floor by at most
309    // `max_checkpoints_per_tick` checkpoints so a large backlog drains
310    // across many ticks instead of one long blocking pass. The floor
311    // converges to `target_lo` over subsequent ticks.
312    let tick_target = target_lo.min(cursor.checkpoint_lo + config.max_checkpoints_per_tick);
313
314    info!(
315        from = cursor.checkpoint_lo,
316        to = tick_target,
317        target = target_lo,
318        current_epoch,
319        "rpc-store pruner: advancing floor"
320    );
321
322    while cursor.checkpoint_lo < tick_target {
323        let chunk_ckpt_hi = (cursor.checkpoint_lo + config.max_chunk_checkpoints).min(tick_target);
324        cursor = prune_chunk(db, schema, cursor, chunk_ckpt_hi, metrics)?;
325        metrics.checkpoint_lo.set(cursor.checkpoint_lo as i64);
326        metrics.tx_seq_lo.set(cursor.tx_seq_lo as i64);
327        metrics.chunks_committed.inc();
328    }
329
330    // A bitmap row written as a merge operand may need one covering
331    // compaction to materialize and another to be filtered. Force a
332    // pass after reaching the retention target; the bitmap CFs'
333    // periodic compaction policy supplies subsequent passes. While a
334    // backlog is draining, skip whole-CF compaction so it does not
335    // become the per-tick long pole.
336    if cursor.checkpoint_lo >= target_lo {
337        db.compact_range_cf(transaction_bitmap::NAME, None, None)
338            .context("Compacting transaction_bitmap after prune")?;
339        db.compact_range_cf(event_bitmap::NAME, None, None)
340            .context("Compacting event_bitmap after prune")?;
341    }
342
343    Ok(())
344}
345
346/// Prune one chunk of checkpoints `[cursor.checkpoint_lo,
347/// chunk_ckpt_hi)` and their transactions, returning the new floor.
348fn prune_chunk(
349    db: &Db,
350    schema: &RpcStoreSchema,
351    cursor: Watermarks,
352    chunk_ckpt_hi: u64,
353    metrics: &PrunerMetrics,
354) -> anyhow::Result<Watermarks> {
355    let ckpt_lo = cursor.checkpoint_lo;
356    let tx_lo = cursor.tx_seq_lo;
357
358    // The exclusive `tx_seq` upper bound for the chunk is the
359    // cumulative network tx count after the chunk's highest
360    // checkpoint, which is the first `tx_seq` of `chunk_ckpt_hi`.
361    // `chunk_ckpt_hi >= 1` by the caller's loop invariant, and
362    // `chunk_ckpt_hi - 1 >= ckpt_lo` is still retained (not yet
363    // pruned), so its summary is present.
364    let last_ckpt = chunk_ckpt_hi - 1;
365    let tx_hi = schema
366        .get_checkpoint_summary(last_ckpt)?
367        .with_context(|| format!("checkpoint_summary missing for checkpoint {last_ckpt}"))?
368        .data()
369        .network_total_transactions;
370
371    let mut batch = db.batch();
372    let mut retractions = Retractions::default();
373    let mut objects_deleted: u64 = 0;
374    // Walk each pruned checkpoint and the transactions it contains.
375    // Consecutive summaries' `network_total_transactions` partition
376    // `[tx_lo, tx_hi)` into per-checkpoint tx ranges, so the containing
377    // checkpoint of every transaction is known here -- it is exactly
378    // the `seq` being walked -- without a per-transaction metadata
379    // lookup. Each effects row yields the object versions to retract
380    // and the transaction digest to unindex; a missing effects row
381    // means that transaction was already pruned (idempotent re-run).
382    let mut tx_cursor = tx_lo;
383    for seq in ckpt_lo..chunk_ckpt_hi {
384        // Every in-range summary is still present: the chunk has not
385        // deleted any yet, and prior chunks committed atomically. A
386        // miss is therefore corruption, not an expected re-run state,
387        // so fail loudly rather than mis-partition the tx range.
388        let summary = schema
389            .get_checkpoint_summary(seq)?
390            .with_context(|| format!("checkpoint_summary missing for checkpoint {seq}"))?;
391        let ckpt_tx_hi = summary.data().network_total_transactions;
392
393        for tx_seq in tx_cursor..ckpt_tx_hi {
394            let Some((effects, _unchanged)) = schema.get_effects(tx_seq)? else {
395                continue;
396            };
397            for (id, version) in effects.modified_at_versions() {
398                batch.delete(&schema.objects, &objects::Key { id, version })?;
399                // Record checkpoint-pinned entries older than this
400                // supersession for per-batch retraction; the entry at
401                // `seq` (the object's final version in this checkpoint)
402                // is kept.
403                retractions.record(id, seq, false);
404                objects_deleted += 1;
405            }
406            for (id, version) in effects.all_tombstones() {
407                batch.delete(&schema.objects, &objects::Key { id, version })?;
408                // The object was removed in `seq`: record that its
409                // tombstone entry at `seq` must be dropped too.
410                retractions.record(id, seq, true);
411                objects_deleted += 1;
412            }
413            batch.delete(
414                &schema.tx_seq_by_digest,
415                &tx_seq_by_digest::Key(*effects.transaction_digest()),
416            )?;
417        }
418        tx_cursor = ckpt_tx_hi;
419
420        // Unindex this checkpoint's digest reverse map.
421        batch.delete(
422            &schema.checkpoint_seq_by_digest,
423            &checkpoint_seq_by_digest::Key(summary.data().digest()),
424        )?;
425    }
426
427    // The `tx_seq`- and checkpoint-keyed CFs are contiguous, so one
428    // range delete each clears the whole chunk regardless of how many
429    // rows it spans.
430    batch.delete_range(&schema.transactions, &U64Be(tx_lo), &U64Be(tx_hi))?;
431    batch.delete_range(&schema.effects, &U64Be(tx_lo), &U64Be(tx_hi))?;
432    batch.delete_range(&schema.events, &U64Be(tx_lo), &U64Be(tx_hi))?;
433    batch.delete_range(&schema.tx_metadata_by_seq, &U64Be(tx_lo), &U64Be(tx_hi))?;
434    batch.delete_range(
435        &schema.checkpoint_summary,
436        &U64Be(ckpt_lo),
437        &U64Be(chunk_ckpt_hi),
438    )?;
439    batch.delete_range(
440        &schema.checkpoint_contents,
441        &U64Be(ckpt_lo),
442        &U64Be(chunk_ckpt_hi),
443    )?;
444
445    retractions.stage(&mut batch, schema)?;
446
447    // Advance the persisted floor atomically with the deletes.
448    let new = Watermarks {
449        tx_seq_lo: tx_hi,
450        checkpoint_lo: chunk_ckpt_hi,
451    };
452    let (k, v) = pruning_watermark::store(&new);
453    batch.put(&schema.pruning_watermark, &k, &v)?;
454
455    batch.commit()?;
456
457    // The commit is durable; advance the in-memory bitmap floor so
458    // the compaction filters drop buckets below `tx_hi`.
459    schema.set_pruning_floor(new.tx_seq_lo);
460    metrics.objects_deleted.inc_by(objects_deleted);
461
462    Ok(new)
463}
464
465/// Retract `object_version_by_checkpoint` rows for one object, given the
466/// greatest checkpoint in a prune batch that superseded or removed it, in
467/// lockstep with the `objects` CF.
468///
469/// Point-deletes every checkpoint-pinned entry for `id` strictly older than
470/// `cp` by walking the object's own prefix over `[id||0, id||cp)` and issuing a
471/// targeted delete for each row present. The bounds stay within `id`'s prefix,
472/// so the scan never spills into the neighboring object. Callers coalesce
473/// repeated supersessions for the same object within a batch before calling this
474/// helper (see [`Retractions`]), so this prefix is walked once, at the greatest
475/// `cp`, whose row set is the union of every narrower retraction's -- including
476/// any removal below `cp`.
477///
478/// Point deletes rather than one `delete_range [id||0, id||cp)`: successive prune
479/// batches retract a hot object at an ever-greater `cp`, all sharing the `id||0`
480/// start, so the range tombstones nest and RocksDB fragments `K` of them into
481/// `O(K^2)` `(fragment, seqnum)` pairs -- at flush, at compaction, and at read.
482/// Ordinary point tombstones carry no such structure; the cost is one entry per
483/// deleted row and a bounded prefix scan on the delete path (already-retracted
484/// rows below a prior floor were deleted by earlier batches, so the scan surfaces
485/// only the rows this batch newly retires).
486///
487/// Once the floor advances past `cp`, the entry at `cp` (or a newer one) is the
488/// floor a checkpoint-pinned read resolves to, so the older entries can never
489/// be the answer again. Because the chunk only prunes checkpoints below the new
490/// floor, `cp` is itself below the floor, so the kept entry is never the answer
491/// to an in-range read either; it survives only until its own superseding
492/// transaction is pruned in a later chunk.
493///
494/// The entry *at* `cp` is kept for a supersession (it is the object's final
495/// live version in `cp`). When `removed` is set, the object was deleted or
496/// wrapped in `cp`: its tombstone entry at `cp` is dropped too, since nothing
497/// at or after the floor can reference a removed object.
498fn retract_object_version_by_checkpoint(
499    batch: &mut Batch,
500    schema: &RpcStoreSchema,
501    id: ObjectID,
502    cp: u64,
503    removed: bool,
504) -> anyhow::Result<()> {
505    let lo = object_version_by_checkpoint::Key { id, checkpoint: 0 };
506    let hi = object_version_by_checkpoint::Key { id, checkpoint: cp };
507    for entry in schema
508        .object_version_by_checkpoint
509        .iter((Bound::Included(lo), Bound::Excluded(hi)))?
510    {
511        let (key, _value) = entry?;
512        batch.delete(&schema.object_version_by_checkpoint, &key)?;
513    }
514    if removed {
515        batch.delete(&schema.object_version_by_checkpoint, &hi)?;
516    }
517    Ok(())
518}
519
520/// Prune the embedded fullnode's history cohort up to a floor supplied
521/// by the validator's perpetual-store pruner.
522///
523/// Unlike [`start_pruner`], this is not epoch-driven and not a
524/// `Service`. The embedded deployment deactivates the raw chain-data
525/// CFs (`transactions`, `effects`, `events`, `objects`,
526/// `checkpoint_*`), so it cannot derive a retention floor or read the
527/// raw effects itself. Instead the perpetual pruner — which owns the raw
528/// data — supplies the floor and the pruned checkpoints' `effects`
529/// directly, and this prunes exactly the history-cohort CFs that grow
530/// without bound:
531///
532/// - `tx_metadata_by_seq` — range-deleted over
533///   `[old_tx_lo, pruned_tx_seq_exclusive)`.
534/// - `tx_seq_by_digest` — point-deleted; the digests are read from
535///   `tx_metadata_by_seq` (the only history CF that still carries them)
536///   over the pruned range, before that range is deleted.
537/// - `object_version_by_checkpoint` — retracted effects-driven through the
538///   same per-batch deduped retraction path as the standalone `prune_chunk`
539///   (the paired `objects` delete lives in that caller, not the helper, and
540///   the embedded store has no `objects` CF): each effect carries the
541///   checkpoint it was pruned from, and repeated retractions for one object
542///   are coalesced to the greatest checkpoint, so a superseded object keeps
543///   only its supersession-checkpoint row — the anchor a point-in-time read at
544///   the floor resolves to — and a removed object drops its rows (a later
545///   wrap/unwrap re-creation at or above the floor survives).
546/// - `transaction_bitmap` / `event_bitmap` — evicted by advancing the
547///   database-local `tx_seq` floor so their compaction filters drop
548///   fully-pruned buckets during periodic compaction.
549///
550/// The live cohort, `package_versions`, and the tiny `epochs` CF are
551/// never pruned.
552///
553/// `pruned_checkpoint_watermark` is the highest checkpoint the
554/// perpetual store has pruned (inclusive); `pruned_tx_seq_exclusive` is
555/// the first still-retained `tx_seq`. The pruner consumes the same floor
556/// the perpetual store prunes to, so the embedded rpc-store's history
557/// cohort stays in lockstep with it. Idempotent: a re-run with the same
558/// or a lower floor is a no-op.
559///
560/// Ordering contract: the caller must invoke this BEFORE durably
561/// committing its own prune of the same checkpoints. The
562/// `object_version_by_checkpoint` retraction is driven by the `effects`
563/// passed in this call and is never re-derived; if the caller's floor
564/// committed first, a crash between the two commits would skip these
565/// effects forever and leak the rows they retract. Committing this side
566/// first is safe precisely because a re-run is idempotent.
567pub fn prune_history_cohort(
568    db: &Db,
569    schema: &RpcStoreSchema,
570    pruned_checkpoint_watermark: u64,
571    pruned_tx_seq_exclusive: u64,
572    effects: &[(u64, TransactionEffects)],
573) -> anyhow::Result<()> {
574    let cursor = schema.get_pruning_watermarks()?.unwrap_or_default();
575    let tx_lo = cursor.tx_seq_lo;
576    let tx_hi = pruned_tx_seq_exclusive;
577    // Lowest still-available checkpoint after this prune: the perpetual
578    // store has pruned through `pruned_checkpoint_watermark` inclusive.
579    let checkpoint_lo = pruned_checkpoint_watermark.saturating_add(1);
580
581    // No-op if the floor would not advance on either axis (idempotent
582    // re-run, or the perpetual floor is behind ours).
583    if tx_hi <= tx_lo && checkpoint_lo <= cursor.checkpoint_lo {
584        return Ok(());
585    }
586
587    let mut batch = db.batch();
588    let mut retractions = Retractions::default();
589    // Unindex the digest reverse map for the pruned `tx_seq` range. The
590    // digests live in `tx_metadata_by_seq`; iterate it (seeking to the
591    // first present row) rather than point-getting each `tx_seq`, so a
592    // sparse range or an unknown (zero) floor costs work proportional to
593    // the rows present, not to the width of the interval.
594    for entry in schema.iter_tx_seq_digests(tx_lo, tx_hi)? {
595        let (_tx_seq, digest) = entry?;
596        batch.delete(&schema.tx_seq_by_digest, &tx_seq_by_digest::Key(digest))?;
597    }
598    batch.delete_range(&schema.tx_metadata_by_seq, &U64Be(tx_lo), &U64Be(tx_hi))?;
599
600    // Retract `object_version_by_checkpoint` for every object the pruned
601    // checkpoints superseded or removed, reusing the same per-batch deduped
602    // effects-driven path as the standalone `prune_chunk` (its paired
603    // `objects` delete lives in that caller, not the helper, and the embedded
604    // store has no `objects` CF). Each effect carries the checkpoint it was
605    // pruned from, and repeated retractions for one object are coalesced to the
606    // greatest checkpoint, so the retraction keeps each object's anchor at its
607    // true latest supersession checkpoint and drops the older ones; a removed
608    // object drops its tombstone too.
609    for (checkpoint, effects) in effects {
610        for (id, _version) in effects.modified_at_versions() {
611            retractions.record(id, *checkpoint, false);
612        }
613        for (id, _version) in effects.all_tombstones() {
614            retractions.record(id, *checkpoint, true);
615        }
616    }
617    retractions.stage(&mut batch, schema)?;
618
619    // Advance the persisted floor atomically with the deletes, taking
620    // the monotonic max on each axis so a stale lower floor never
621    // regresses an axis the other call already advanced.
622    let new = Watermarks {
623        tx_seq_lo: tx_hi.max(tx_lo),
624        checkpoint_lo: checkpoint_lo.max(cursor.checkpoint_lo),
625    };
626    let (k, v) = pruning_watermark::store(&new);
627    batch.put(&schema.pruning_watermark, &k, &v)?;
628    batch.commit()?;
629
630    // Durable now: advance the in-memory bitmap floor so the bitmap
631    // compaction filters start dropping fully-pruned buckets on the next
632    // natural background compaction. The prune forces no sweep of its own:
633    // compacting on every prune batch is far more compaction work than the
634    // reclaimed space is worth.
635    schema.set_pruning_floor(new.tx_seq_lo);
636
637    Ok(())
638}
639
640/// The highest checkpoint the embedded fullnode's pruner may prune
641/// through (inclusive) without deleting source data the embedded
642/// indexer still needs: `min(checkpoint_hi_inclusive)` across every
643/// embedded-cohort pipeline ([`LIVE_COHORT`] and [`HISTORY_COHORT`]).
644///
645/// Both cohorts assemble full checkpoints from the perpetual and
646/// checkpoint stores through the local ingestion client — the history
647/// cohort while backfilling `(L, T]`, the live cohort when filling
648/// gaps behind the executor's broadcast stream — so a checkpoint's
649/// data may only be deleted once every pipeline has committed it.
650/// Pruning past a pipeline's watermark would leave that pipeline
651/// permanently stalled on a checkpoint that can no longer be served
652/// (`NotFound` is retried forever).
653///
654/// Returns `None` when any cohort pipeline has no watermark yet — a
655/// from-genesis build before that pipeline's first commit — in which
656/// case nothing may be pruned: the pipeline still needs the entire
657/// available range.
658///
659/// [`LIVE_COHORT`]: crate::LIVE_COHORT
660/// [`HISTORY_COHORT`]: crate::HISTORY_COHORT
661pub fn embedded_prunable_checkpoint(db: &Db) -> anyhow::Result<Option<u64>> {
662    let framework = db.framework();
663    let mut min_hi: Option<u64> = None;
664    for name in LIVE_COHORT.iter().chain(HISTORY_COHORT) {
665        let key = PipelineTaskKey::new(*name);
666        let Some(watermark) = framework
667            .watermarks
668            .get(&key)
669            .with_context(|| format!("reading watermark for {name}"))?
670        else {
671            return Ok(None);
672        };
673        let hi = watermark.checkpoint_hi_inclusive;
674        min_hi = Some(min_hi.map_or(hi, |m| m.min(hi)));
675    }
676    Ok(min_hi)
677}
678
679/// The lowest epoch fully committed across every registered pipeline,
680/// or `None` if no pipeline has committed a watermark yet.
681///
682/// Taking the minimum is deliberately conservative: it lags the true
683/// tip epoch by at most one epoch while a pipeline catches up across
684/// a boundary, which only ever causes the pruner to retain slightly
685/// more.
686fn current_committed_epoch(db: &Db) -> anyhow::Result<Option<u64>> {
687    let framework = FrameworkSchema::new(db.clone());
688    let mut min_epoch: Option<u64> = None;
689    for entry in framework.watermarks.iter(..)? {
690        let (_, watermark) = entry?;
691        let epoch = watermark.epoch_hi_inclusive;
692        min_epoch = Some(min_epoch.map_or(epoch, |m| m.min(epoch)));
693    }
694    Ok(min_epoch)
695}
696
697/// The target checkpoint floor implied by epoch-based retention: the
698/// start checkpoint of the oldest epoch that is still retained.
699///
700/// Returns `None` when nothing is eligible yet — either the chain is
701/// younger than the retention window, or the oldest retained epoch's
702/// row (or its `start_checkpoint`) has not been observed.
703fn retention_checkpoint_floor(
704    schema: &RpcStoreSchema,
705    current_epoch: u64,
706    retention_epochs: u64,
707) -> anyhow::Result<Option<u64>> {
708    debug_assert!(retention_epochs >= 1, "validated in start_pruner");
709
710    // Retain epochs `[oldest_retained, current_epoch]`.
711    let oldest_retained = current_epoch.saturating_sub(retention_epochs - 1);
712    if oldest_retained == 0 {
713        // Epoch 0 is still retained, so no epoch has fully aged out.
714        return Ok(None);
715    }
716
717    let Some(info) = schema.get_epoch(oldest_retained)? else {
718        return Ok(None);
719    };
720    Ok(info.start_checkpoint)
721}
722
723/// Clamp the retention-derived floor so it never advances past the
724/// oldest in-memory snapshot's checkpoint. With no snapshots the
725/// retention floor stands; otherwise the floor is held at or below
726/// the oldest snapshot so that snapshot's advertised available range
727/// stays valid (and the bitmap compaction filter, which ignores
728/// snapshots, never drops a bucket the snapshot still serves).
729fn clamp_to_snapshot(retention_lo: u64, oldest_snapshot: Option<u64>) -> u64 {
730    match oldest_snapshot {
731        Some(snap) => retention_lo.min(snap),
732        None => retention_lo,
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use std::sync::Arc;
739
740    use prometheus::Registry;
741    use sui_consistent_store::Db;
742    use sui_consistent_store::DbOptions;
743    use sui_consistent_store::PipelineTaskKey;
744    use sui_consistent_store::Watermark;
745    use sui_indexer_alt_framework::pipeline::Processor;
746    use sui_types::test_checkpoint_data_builder::TestCheckpointBuilder;
747
748    use super::*;
749    use crate::schema::epochs;
750    use crate::schema::primitives::U64Varint;
751
752    fn fresh_db() -> (tempfile::TempDir, Db, RpcStoreSchema) {
753        let dir = tempfile::tempdir().unwrap();
754        let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
755        (dir, db, schema)
756    }
757
758    /// Stamp `checkpoint_hi_inclusive = hi` watermarks for `names`.
759    fn stamp_watermarks(db: &Db, names: &[&str], hi: u64) {
760        let framework = FrameworkSchema::new(db.clone());
761        let mut batch = db.batch();
762        for name in names {
763            batch
764                .put(
765                    &framework.watermarks,
766                    &PipelineTaskKey::new(*name),
767                    &Watermark::for_checkpoint(hi),
768                )
769                .unwrap();
770        }
771        batch.commit().unwrap();
772    }
773
774    /// `embedded_prunable_checkpoint` is the minimum watermark across
775    /// both embedded cohorts, and `None` while any cohort pipeline has
776    /// no watermark at all.
777    #[test]
778    fn embedded_prunable_checkpoint_is_min_across_cohorts() {
779        let (_dir, db, _schema) = fresh_db();
780
781        // Fresh database: nothing committed, nothing prunable.
782        assert_eq!(embedded_prunable_checkpoint(&db).unwrap(), None);
783
784        // Live cohort at the tip, history cohort still absent (e.g. a
785        // from-genesis backfill before its first commit): still
786        // nothing prunable.
787        stamp_watermarks(&db, LIVE_COHORT, 1_000);
788        assert_eq!(embedded_prunable_checkpoint(&db).unwrap(), None);
789
790        // Every history pipeline committed through 40 except one
791        // straggler at 25: the straggler bounds the prunable range.
792        stamp_watermarks(&db, HISTORY_COHORT, 40);
793        stamp_watermarks(&db, &[HISTORY_COHORT[0]], 25);
794        assert_eq!(embedded_prunable_checkpoint(&db).unwrap(), Some(25));
795
796        // The straggler catches up past the live cohort: the live
797        // cohort's watermark now bounds the range.
798        stamp_watermarks(&db, HISTORY_COHORT, 2_000);
799        assert_eq!(embedded_prunable_checkpoint(&db).unwrap(), Some(1_000));
800    }
801
802    /// Populate the CFs the pruner reads and deletes by running the
803    /// real pipelines' `process` over `checkpoint` and staging their
804    /// rows — `objects`, `effects`, `checkpoint_summary`, and the two
805    /// digest reverse indexes. These cover both deletion mechanisms
806    /// (range delete and point delete) plus the effects-driven object
807    /// retraction.
808    async fn seed(
809        db: &Db,
810        schema: &RpcStoreSchema,
811        checkpoint: &Arc<sui_types::full_checkpoint_content::Checkpoint>,
812    ) {
813        let mut batch = db.batch();
814        for row in crate::indexer::objects::Objects
815            .process(checkpoint)
816            .await
817            .unwrap()
818        {
819            batch
820                .put(
821                    &schema.objects,
822                    &objects::Key {
823                        id: row.id,
824                        version: row.version,
825                    },
826                    &row.value,
827                )
828                .unwrap();
829        }
830        for row in crate::indexer::effects::Effects
831            .process(checkpoint)
832            .await
833            .unwrap()
834        {
835            batch
836                .put(&schema.effects, &U64Be(row.tx_seq), &row.value)
837                .unwrap();
838        }
839        for row in crate::indexer::checkpoint_summary::CheckpointSummary
840            .process(checkpoint)
841            .await
842            .unwrap()
843        {
844            batch
845                .put(&schema.checkpoint_summary, &U64Be(row.seq), &row.value)
846                .unwrap();
847        }
848        for row in crate::indexer::tx_seq_by_digest::TxSeqByDigest
849            .process(checkpoint)
850            .await
851            .unwrap()
852        {
853            batch
854                .put(
855                    &schema.tx_seq_by_digest,
856                    &tx_seq_by_digest::Key(row.digest),
857                    &U64Varint(row.tx_seq),
858                )
859                .unwrap();
860        }
861        for row in crate::indexer::checkpoint_seq_by_digest::CheckpointSeqByDigest
862            .process(checkpoint)
863            .await
864            .unwrap()
865        {
866            batch
867                .put(
868                    &schema.checkpoint_seq_by_digest,
869                    &checkpoint_seq_by_digest::Key(row.digest),
870                    &U64Varint(row.seq),
871                )
872                .unwrap();
873        }
874        batch.commit().unwrap();
875    }
876
877    fn seed_checkpoint_versions(
878        db: &Db,
879        schema: &RpcStoreSchema,
880        id: ObjectID,
881        rows: &[(u64, u64)],
882    ) {
883        let mut batch = db.batch();
884        for &(checkpoint, version) in rows {
885            let (k, v) = object_version_by_checkpoint::store(
886                id,
887                checkpoint,
888                sui_types::base_types::SequenceNumber::from_u64(version),
889            );
890            batch
891                .put(&schema.object_version_by_checkpoint, &k, &v)
892                .unwrap();
893        }
894        batch.commit().unwrap();
895    }
896
897    #[test]
898    fn retractions_stage_widest_range_per_object() {
899        let (_dir, db, schema) = fresh_db();
900        let obj = TestCheckpointBuilder::derive_object_id(0);
901        seed_checkpoint_versions(&db, &schema, obj, &[(0, 1), (1, 2), (2, 3)]);
902
903        let mut retractions = Retractions::default();
904        retractions.record(obj, 1, true);
905        retractions.record(obj, 2, false);
906        let mut batch = db.batch();
907        retractions.stage(&mut batch, &schema).unwrap();
908        batch.commit().unwrap();
909
910        assert_eq!(
911            schema.get_object_version_at_checkpoint(obj, 1).unwrap(),
912            None,
913            "the widest range must cover lower-checkpoint removals",
914        );
915        assert_eq!(
916            schema.get_object_version_at_checkpoint(obj, 2).unwrap(),
917            Some(sui_types::base_types::SequenceNumber::from_u64(3)),
918            "a lower removed=true retraction must not delete the latest anchor",
919        );
920    }
921
922    #[test]
923    fn retractions_or_removed_on_equal_checkpoint() {
924        let (_dir, db, schema) = fresh_db();
925        let obj = TestCheckpointBuilder::derive_object_id(0);
926        seed_checkpoint_versions(&db, &schema, obj, &[(0, 1), (2, 3)]);
927
928        let mut retractions = Retractions::default();
929        retractions.record(obj, 2, false);
930        retractions.record(obj, 2, true);
931        let mut batch = db.batch();
932        retractions.stage(&mut batch, &schema).unwrap();
933        batch.commit().unwrap();
934
935        assert_eq!(
936            schema.get_object_version_at_checkpoint(obj, 2).unwrap(),
937            None,
938            "same-checkpoint removals must drop the checkpoint row",
939        );
940    }
941
942    /// A hot object with deep checkpoint-pinned history (the Clock /
943    /// SuiSystemState shape, superseded in every checkpoint) is cleared below
944    /// the coalesced retraction checkpoint in a single pass, keeping only the
945    /// anchor at that checkpoint. Exercises the point-delete prefix walk that
946    /// replaced the per-object range delete, and confirms it deletes exactly
947    /// the rows in `[id||0, id||cp)` without spilling into the next object.
948    #[test]
949    fn retraction_point_deletes_deep_history() {
950        let (_dir, db, schema) = fresh_db();
951        let obj = TestCheckpointBuilder::derive_object_id(0);
952        let neighbor = TestCheckpointBuilder::derive_object_id(1);
953
954        // The object changed in every checkpoint 0..1000 (version = cp + 1);
955        // seed a neighboring object below the retraction floor to prove the
956        // bounded scan does not cross the id boundary.
957        let rows: Vec<(u64, u64)> = (0..1_000u64).map(|c| (c, c + 1)).collect();
958        seed_checkpoint_versions(&db, &schema, obj, &rows);
959        seed_checkpoint_versions(&db, &schema, neighbor, &[(10, 42)]);
960
961        // Superseded in every checkpoint: without coalescing this would walk
962        // the prefix 1000 times; the collector reduces it to one retraction at
963        // the greatest checkpoint (999).
964        let mut retractions = Retractions::default();
965        for (checkpoint, _) in &rows {
966            retractions.record(obj, *checkpoint, false);
967        }
968        let mut batch = db.batch();
969        retractions.stage(&mut batch, &schema).unwrap();
970        batch.commit().unwrap();
971
972        // Everything below 999 is gone; the anchor at 999 survives as the floor
973        // a point-in-time read resolves to.
974        assert_eq!(
975            schema.get_object_version_at_checkpoint(obj, 998).unwrap(),
976            None,
977            "history below the coalesced checkpoint must be fully point-deleted",
978        );
979        assert_eq!(
980            schema.get_object_version_at_checkpoint(obj, 999).unwrap(),
981            Some(sui_types::base_types::SequenceNumber::from_u64(1_000)),
982            "the anchor at the coalesced checkpoint must survive",
983        );
984        let remaining: Vec<u64> = schema
985            .iter_object_versions_by_checkpoint(obj)
986            .unwrap()
987            .map(|r| r.unwrap().0.checkpoint)
988            .collect();
989        assert_eq!(remaining, vec![999], "only the anchor row remains");
990
991        // The neighboring object's rows are untouched.
992        assert_eq!(
993            schema
994                .get_object_version_at_checkpoint(neighbor, 10)
995                .unwrap(),
996            Some(sui_types::base_types::SequenceNumber::from_u64(42)),
997            "the bounded scan must not delete a neighboring object's rows",
998        );
999    }
1000
1001    #[test]
1002    fn clamp_to_snapshot_holds_floor_at_or_below_oldest_snapshot() {
1003        // No snapshots: retention floor stands.
1004        assert_eq!(clamp_to_snapshot(100, None), 100);
1005        // Retention is well below the oldest snapshot: retention binds.
1006        assert_eq!(clamp_to_snapshot(100, Some(250)), 100);
1007        // Retention would overrun the oldest snapshot: clamp holds.
1008        assert_eq!(clamp_to_snapshot(300, Some(250)), 250);
1009        // Exactly at the oldest snapshot is allowed.
1010        assert_eq!(clamp_to_snapshot(250, Some(250)), 250);
1011    }
1012
1013    #[test]
1014    fn retention_floor_none_when_chain_younger_than_window() {
1015        let (_dir, _db, schema) = fresh_db();
1016        // current_epoch=2, retention=5 => oldest_retained saturates to
1017        // 0, so epoch 0 is still retained and nothing has aged out.
1018        assert!(retention_checkpoint_floor(&schema, 2, 5).unwrap().is_none());
1019    }
1020
1021    #[test]
1022    fn retention_floor_is_start_checkpoint_of_oldest_retained_epoch() {
1023        let (_dir, db, schema) = fresh_db();
1024        // Seed epoch 3's start record at checkpoint 300.
1025        let mut batch = db.batch();
1026        batch
1027            .merge(
1028                &schema.epochs,
1029                &U64Be(3),
1030                &epochs::start(1, 1, 0, Some(300), None),
1031            )
1032            .unwrap();
1033        batch.commit().unwrap();
1034        // current_epoch=5, retention=3 => retain [3, 5], oldest
1035        // retained is epoch 3, whose start checkpoint is the floor.
1036        assert_eq!(
1037            retention_checkpoint_floor(&schema, 5, 3).unwrap(),
1038            Some(300)
1039        );
1040    }
1041
1042    #[test]
1043    fn retention_floor_none_when_oldest_epoch_row_missing() {
1044        let (_dir, _db, schema) = fresh_db();
1045        // Oldest retained epoch is 9, but no row has been observed.
1046        assert!(
1047            retention_checkpoint_floor(&schema, 10, 2)
1048                .unwrap()
1049                .is_none()
1050        );
1051    }
1052
1053    #[test]
1054    fn current_committed_epoch_takes_min_across_watermarks() {
1055        let (_dir, db, _schema) = fresh_db();
1056        let framework = FrameworkSchema::new(db.clone());
1057        let mut batch = db.batch();
1058        batch
1059            .put(
1060                &framework.watermarks,
1061                &PipelineTaskKey::new("a"),
1062                &Watermark {
1063                    epoch_hi_inclusive: 7,
1064                    ..Default::default()
1065                },
1066            )
1067            .unwrap();
1068        batch
1069            .put(
1070                &framework.watermarks,
1071                &PipelineTaskKey::new("b"),
1072                &Watermark {
1073                    epoch_hi_inclusive: 5,
1074                    ..Default::default()
1075                },
1076            )
1077            .unwrap();
1078        batch.commit().unwrap();
1079        assert_eq!(current_committed_epoch(&db).unwrap(), Some(5));
1080    }
1081
1082    #[test]
1083    fn current_committed_epoch_none_when_no_watermarks() {
1084        let (_dir, db, _schema) = fresh_db();
1085        assert!(current_committed_epoch(&db).unwrap().is_none());
1086    }
1087
1088    /// Production-shaped bitmap reclamation: merge operands survive the
1089    /// first covering compaction that materializes them, then expired
1090    /// buckets are filtered on the second while retained buckets remain.
1091    #[test]
1092    fn merge_written_bitmap_buckets_require_two_compactions_for_reclamation() {
1093        let (_dir, db, schema) = fresh_db();
1094        let dimension = b"sender:alice".to_vec();
1095        let floor = transaction_bitmap::TX_BUCKET_SIZE;
1096        let retained_tx_seq = floor + 5;
1097
1098        let (tx_low_key, tx_low_value) = transaction_bitmap::store_match(dimension.clone(), 5);
1099        let (tx_high_key, tx_high_value) =
1100            transaction_bitmap::store_match(dimension.clone(), retained_tx_seq);
1101        let (event_low_key, event_low_value) = event_bitmap::store_match(dimension.clone(), 5, 0);
1102        let (event_high_key, event_high_value) =
1103            event_bitmap::store_match(dimension.clone(), retained_tx_seq, 0);
1104
1105        let mut batch = db.batch();
1106        batch
1107            .merge(&schema.transaction_bitmap, &tx_low_key, &tx_low_value)
1108            .unwrap();
1109        batch
1110            .merge(&schema.transaction_bitmap, &tx_high_key, &tx_high_value)
1111            .unwrap();
1112        batch
1113            .merge(&schema.event_bitmap, &event_low_key, &event_low_value)
1114            .unwrap();
1115        batch
1116            .merge(&schema.event_bitmap, &event_high_key, &event_high_value)
1117            .unwrap();
1118        batch.commit().unwrap();
1119        db.flush().unwrap();
1120
1121        let (watermark_key, watermark_value) = pruning_watermark::store(&Watermarks {
1122            tx_seq_lo: floor,
1123            checkpoint_lo: 1,
1124        });
1125        let mut batch = db.batch();
1126        batch
1127            .put(&schema.pruning_watermark, &watermark_key, &watermark_value)
1128            .unwrap();
1129        batch.commit().unwrap();
1130        schema.set_pruning_floor(floor);
1131
1132        db.compact_range_cf(transaction_bitmap::NAME, None, None)
1133            .unwrap();
1134        db.compact_range_cf(event_bitmap::NAME, None, None).unwrap();
1135
1136        assert!(
1137            schema
1138                .get_transaction_bitmap(dimension.clone(), tx_low_key.bucket)
1139                .unwrap()
1140                .is_some()
1141        );
1142        assert!(
1143            schema
1144                .get_transaction_bitmap(dimension.clone(), tx_high_key.bucket)
1145                .unwrap()
1146                .is_some()
1147        );
1148        assert!(
1149            schema
1150                .get_event_bitmap(dimension.clone(), event_low_key.bucket)
1151                .unwrap()
1152                .is_some()
1153        );
1154        assert!(
1155            schema
1156                .get_event_bitmap(dimension.clone(), event_high_key.bucket)
1157                .unwrap()
1158                .is_some()
1159        );
1160
1161        db.compact_range_cf(transaction_bitmap::NAME, None, None)
1162            .unwrap();
1163        db.compact_range_cf(event_bitmap::NAME, None, None).unwrap();
1164
1165        assert!(
1166            schema
1167                .get_transaction_bitmap(dimension.clone(), tx_low_key.bucket)
1168                .unwrap()
1169                .is_none()
1170        );
1171        assert!(
1172            schema
1173                .get_transaction_bitmap(dimension.clone(), tx_high_key.bucket)
1174                .unwrap()
1175                .is_some()
1176        );
1177        assert!(
1178            schema
1179                .get_event_bitmap(dimension.clone(), event_low_key.bucket)
1180                .unwrap()
1181                .is_none()
1182        );
1183        assert!(
1184            schema
1185                .get_event_bitmap(dimension, event_high_key.bucket)
1186                .unwrap()
1187                .is_some()
1188        );
1189    }
1190
1191    /// A committed chunk publishes its `tx_seq_lo` to this database's
1192    /// bitmap compaction filters.
1193    #[tokio::test]
1194    async fn prune_chunk_publishes_the_db_local_bitmap_floor() {
1195        let (_dir, db, schema) = fresh_db();
1196        let checkpoint = Arc::new(
1197            TestCheckpointBuilder::new(0)
1198                .start_transaction(0)
1199                .create_owned_object(0)
1200                .finish_transaction()
1201                .start_transaction(0)
1202                .transfer_object(0, 1)
1203                .finish_transaction()
1204                .build_checkpoint(),
1205        );
1206        seed(&db, &schema, &checkpoint).await;
1207
1208        let metrics = PrunerMetrics::new(None, &Registry::new());
1209        let new = prune_chunk(&db, &schema, Watermarks::default(), 1, &metrics).unwrap();
1210
1211        assert_eq!(
1212            schema.current_pruning_floor(),
1213            new.tx_seq_lo,
1214            "the chunk must publish its committed tx_seq floor",
1215        );
1216    }
1217
1218    #[test]
1219    fn start_pruner_rejects_zero_retention() {
1220        let (_dir, db, schema) = fresh_db();
1221        let store = Store::new(db, Arc::new(schema));
1222        let config = PrunerConfig {
1223            retention_epochs: 0,
1224            ..PrunerConfig::default()
1225        };
1226        let err =
1227            start_pruner(store, config, PrunerMetrics::new(None, &Registry::new())).unwrap_err();
1228        assert!(
1229            format!("{err:#}").contains("retention_epochs"),
1230            "expected a retention_epochs validation error, got: {err:#}",
1231        );
1232    }
1233
1234    #[test]
1235    fn start_pruner_rejects_zero_checkpoints_per_tick() {
1236        let (_dir, db, schema) = fresh_db();
1237        let store = Store::new(db, Arc::new(schema));
1238        let config = PrunerConfig {
1239            max_checkpoints_per_tick: 0,
1240            ..PrunerConfig::default()
1241        };
1242        let err =
1243            start_pruner(store, config, PrunerMetrics::new(None, &Registry::new())).unwrap_err();
1244        assert!(
1245            format!("{err:#}").contains("max_checkpoints_per_tick"),
1246            "expected a max_checkpoints_per_tick validation error, got: {err:#}",
1247        );
1248    }
1249
1250    /// A single `prune_once` pass advances the floor by at most
1251    /// `max_checkpoints_per_tick` checkpoints, and successive passes
1252    /// converge to the retention target. Five single-transaction
1253    /// checkpoints are eligible (retention floor at checkpoint 5); a
1254    /// per-tick budget of 2 must take three passes to drain them
1255    /// (2, 4, 5), after which the floor sits at the target and further
1256    /// passes are no-ops.
1257    #[tokio::test]
1258    async fn prune_once_advances_at_most_the_per_tick_budget() {
1259        let (_dir, db, schema) = fresh_db();
1260
1261        // Five single-transaction checkpoints (seq 0..=4) from one
1262        // accumulating builder, so `network_total_transactions` grows
1263        // by one per checkpoint and the pruned tx range is contiguous.
1264        let mut builder = TestCheckpointBuilder::new(0);
1265        let mut checkpoints = Vec::new();
1266        for i in 0..5u64 {
1267            builder = builder
1268                .start_transaction(0)
1269                .create_owned_object(i)
1270                .finish_transaction();
1271            checkpoints.push(Arc::new(builder.build_checkpoint()));
1272        }
1273        for cp in &checkpoints {
1274            seed(&db, &schema, cp).await;
1275        }
1276
1277        // Drive the target floor: the committed epoch is 2, and with
1278        // `retention_epochs = 1` the oldest retained epoch is 2, whose
1279        // start checkpoint (5) is the target floor — so checkpoints
1280        // [0, 5) are eligible.
1281        let framework = FrameworkSchema::new(db.clone());
1282        let mut batch = db.batch();
1283        batch
1284            .put(
1285                &framework.watermarks,
1286                &PipelineTaskKey::new("p"),
1287                &Watermark {
1288                    epoch_hi_inclusive: 2,
1289                    ..Default::default()
1290                },
1291            )
1292            .unwrap();
1293        batch
1294            .merge(
1295                &schema.epochs,
1296                &U64Be(2),
1297                &epochs::start(1, 1, 0, Some(5), None),
1298            )
1299            .unwrap();
1300        batch.commit().unwrap();
1301
1302        let config = PrunerConfig {
1303            retention_epochs: 1,
1304            interval_ms: 1,
1305            max_chunk_checkpoints: 2,
1306            max_checkpoints_per_tick: 2,
1307        };
1308        let metrics = PrunerMetrics::new(None, &Registry::new());
1309
1310        let floor = |schema: &RpcStoreSchema| {
1311            schema
1312                .get_pruning_watermarks()
1313                .unwrap()
1314                .unwrap_or_default()
1315                .checkpoint_lo
1316        };
1317
1318        // Each pass advances by at most the per-tick budget of 2.
1319        prune_once(&db, &schema, &config, &metrics).unwrap();
1320        assert_eq!(floor(&schema), 2, "first tick advances by the budget");
1321        prune_once(&db, &schema, &config, &metrics).unwrap();
1322        assert_eq!(floor(&schema), 4, "second tick advances by the budget");
1323        prune_once(&db, &schema, &config, &metrics).unwrap();
1324        assert_eq!(floor(&schema), 5, "third tick reaches the target");
1325
1326        // Caught up: history below the floor is gone, the live target
1327        // boundary is retained, and another pass is a no-op.
1328        assert!(schema.get_effects(4).unwrap().is_none());
1329        assert!(schema.get_checkpoint_summary(4).unwrap().is_none());
1330        prune_once(&db, &schema, &config, &metrics).unwrap();
1331        assert_eq!(floor(&schema), 5, "a pass at the target is a no-op");
1332    }
1333
1334    /// End-to-end chunk prune: one checkpoint where tx0 creates an
1335    /// object and tx1 transfers it (superseding the first version).
1336    /// Pruning the chunk must range-delete the per-tx / per-checkpoint
1337    /// CFs, point-delete the digest reverse indexes, retract the
1338    /// superseded object version, preserve the live version, and
1339    /// advance the persisted floor.
1340    #[tokio::test]
1341    async fn prune_chunk_deletes_history_and_preserves_live_object() {
1342        let (_dir, db, schema) = fresh_db();
1343
1344        let checkpoint = Arc::new(
1345            TestCheckpointBuilder::new(0)
1346                .start_transaction(0)
1347                .create_owned_object(0)
1348                .finish_transaction()
1349                .start_transaction(0)
1350                .transfer_object(0, 1)
1351                .finish_transaction()
1352                .build_checkpoint(),
1353        );
1354
1355        let obj0 = TestCheckpointBuilder::derive_object_id(0);
1356        let v_a = checkpoint.transactions[0].effects.lamport_version();
1357        let v_b = checkpoint.transactions[1].effects.lamport_version();
1358        assert_ne!(v_a, v_b, "the transfer must bump the object's version");
1359        let digest0 = *checkpoint.transactions[0].effects.transaction_digest();
1360        let digest1 = *checkpoint.transactions[1].effects.transaction_digest();
1361        let ckpt_digest = checkpoint.summary.data().digest();
1362
1363        seed(&db, &schema, &checkpoint).await;
1364
1365        // Preconditions: both versions present, history present.
1366        assert!(schema.get_object_by_key(obj0, v_a).unwrap().is_some());
1367        assert!(schema.get_object_by_key(obj0, v_b).unwrap().is_some());
1368        assert!(schema.get_effects(0).unwrap().is_some());
1369        assert!(schema.get_effects(1).unwrap().is_some());
1370        assert!(schema.get_checkpoint_summary(0).unwrap().is_some());
1371
1372        // Prune the whole checkpoint: checkpoints [0, 1), tx [0, 2).
1373        let metrics = PrunerMetrics::new(None, &Registry::new());
1374        let new = prune_chunk(&db, &schema, Watermarks::default(), 1, &metrics).unwrap();
1375        assert_eq!(
1376            new,
1377            Watermarks {
1378                tx_seq_lo: 2,
1379                checkpoint_lo: 1,
1380            },
1381        );
1382
1383        // Superseded version retracted; live version preserved.
1384        assert!(
1385            schema.get_object_by_key(obj0, v_a).unwrap().is_none(),
1386            "superseded version v_a should be pruned",
1387        );
1388        assert!(
1389            schema.get_object_by_key(obj0, v_b).unwrap().is_some(),
1390            "live version v_b must be preserved",
1391        );
1392
1393        // Range-deleted CFs are emptied over the pruned range.
1394        assert!(schema.get_effects(0).unwrap().is_none());
1395        assert!(schema.get_effects(1).unwrap().is_none());
1396        assert!(schema.get_checkpoint_summary(0).unwrap().is_none());
1397
1398        // Point-deleted digest reverse indexes are gone.
1399        assert!(
1400            schema
1401                .tx_seq_by_digest
1402                .get(&tx_seq_by_digest::Key(digest0))
1403                .unwrap()
1404                .is_none()
1405        );
1406        assert!(
1407            schema
1408                .tx_seq_by_digest
1409                .get(&tx_seq_by_digest::Key(digest1))
1410                .unwrap()
1411                .is_none()
1412        );
1413        assert!(
1414            schema
1415                .checkpoint_seq_by_digest
1416                .get(&checkpoint_seq_by_digest::Key(ckpt_digest))
1417                .unwrap()
1418                .is_none()
1419        );
1420
1421        // The persisted floor advanced.
1422        assert_eq!(
1423            schema.get_pruning_watermarks().unwrap().unwrap(),
1424            Watermarks {
1425                tx_seq_lo: 2,
1426                checkpoint_lo: 1,
1427            },
1428        );
1429    }
1430
1431    /// Advance the floor across two single-checkpoint chunks and
1432    /// confirm a superseded object version is retracted only once the
1433    /// chunk containing its *superseding* transaction is pruned.
1434    ///
1435    /// Checkpoint 0 creates `obj0@v_a`; checkpoint 1 transfers it to
1436    /// `obj0@v_b`. Pruning checkpoint 0 alone must keep `v_a` (its
1437    /// superseding transaction is still live); pruning checkpoint 1
1438    /// then retracts `v_a` while preserving the live `v_b`.
1439    #[tokio::test]
1440    async fn prune_chunk_retracts_version_only_when_superseding_tx_is_pruned() {
1441        let (_dir, db, schema) = fresh_db();
1442
1443        // One builder across two checkpoints so `network_total_transactions`
1444        // accumulates and the shared live-object set carries obj0 forward.
1445        let mut builder = TestCheckpointBuilder::new(0)
1446            .start_transaction(0)
1447            .create_owned_object(0)
1448            .finish_transaction();
1449        let cp0 = Arc::new(builder.build_checkpoint());
1450        builder = builder
1451            .start_transaction(0)
1452            .transfer_object(0, 1)
1453            .finish_transaction();
1454        let cp1 = Arc::new(builder.build_checkpoint());
1455
1456        let obj0 = TestCheckpointBuilder::derive_object_id(0);
1457        let v_a = cp0.transactions[0].effects.lamport_version();
1458        let v_b = cp1.transactions[0].effects.lamport_version();
1459        assert_ne!(v_a, v_b);
1460
1461        seed(&db, &schema, &cp0).await;
1462        seed(&db, &schema, &cp1).await;
1463        let metrics = PrunerMetrics::new(None, &Registry::new());
1464
1465        // Chunk 1: prune checkpoint 0 only (tx [0, 1)). obj0's
1466        // superseding transaction is in checkpoint 1, so v_a stays.
1467        let after_first = prune_chunk(&db, &schema, Watermarks::default(), 1, &metrics).unwrap();
1468        assert_eq!(
1469            after_first,
1470            Watermarks {
1471                tx_seq_lo: 1,
1472                checkpoint_lo: 1,
1473            },
1474        );
1475        assert!(schema.get_effects(0).unwrap().is_none());
1476        assert!(schema.get_effects(1).unwrap().is_some());
1477        assert!(
1478            schema.get_object_by_key(obj0, v_a).unwrap().is_some(),
1479            "v_a must survive while its superseding tx is still retained",
1480        );
1481
1482        // Chunk 2: prune checkpoint 1 (tx [1, 2)). Now the superseding
1483        // transaction is pruned, retracting v_a; v_b remains live.
1484        let after_second = prune_chunk(&db, &schema, after_first, 2, &metrics).unwrap();
1485        assert_eq!(
1486            after_second,
1487            Watermarks {
1488                tx_seq_lo: 2,
1489                checkpoint_lo: 2,
1490            },
1491        );
1492        assert!(schema.get_effects(1).unwrap().is_none());
1493        assert!(
1494            schema.get_object_by_key(obj0, v_a).unwrap().is_none(),
1495            "v_a must be retracted once its superseding tx is pruned",
1496        );
1497        assert!(
1498            schema.get_object_by_key(obj0, v_b).unwrap().is_some(),
1499            "live v_b must be preserved",
1500        );
1501    }
1502
1503    /// The checkpoint-pinned `object_version_by_checkpoint` index is
1504    /// retracted in lockstep with the `objects` history: a
1505    /// checkpoint-pinned entry survives until the transaction that
1506    /// supersedes its object is pruned, and is dropped once that
1507    /// transaction's checkpoint ages out.
1508    ///
1509    /// Checkpoint 0 creates `obj0@v_a`; checkpoint 1 transfers it to
1510    /// `obj0@v_b`. Pruning checkpoint 0 keeps the cp0-pinned entry (its
1511    /// superseding transaction is still retained); pruning checkpoint 1
1512    /// retracts it while preserving the cp1-pinned floor entry.
1513    #[tokio::test]
1514    async fn prune_chunk_retracts_object_version_by_checkpoint() {
1515        use crate::indexer::object_version_by_checkpoint::ObjectVersionByCheckpoint;
1516
1517        let (_dir, db, schema) = fresh_db();
1518
1519        let mut builder = TestCheckpointBuilder::new(0)
1520            .start_transaction(0)
1521            .create_owned_object(0)
1522            .finish_transaction();
1523        let cp0 = Arc::new(builder.build_checkpoint());
1524        builder = builder
1525            .start_transaction(0)
1526            .transfer_object(0, 1)
1527            .finish_transaction();
1528        let cp1 = Arc::new(builder.build_checkpoint());
1529
1530        let obj0 = TestCheckpointBuilder::derive_object_id(0);
1531        let v_a = cp0.transactions[0].effects.lamport_version();
1532        let v_b = cp1.transactions[0].effects.lamport_version();
1533        assert_ne!(v_a, v_b);
1534
1535        // Seed the base CFs the pruner reads (`seed` populates
1536        // `checkpoint_summary`, from which the pruner derives each
1537        // transaction's checkpoint) plus the checkpoint-pinned index
1538        // under test.
1539        for cp in [&cp0, &cp1] {
1540            seed(&db, &schema, cp).await;
1541            let mut batch = db.batch();
1542            for row in ObjectVersionByCheckpoint::default()
1543                .process(cp)
1544                .await
1545                .unwrap()
1546            {
1547                // Seed only the change rows; the floor candidates are
1548                // exercised in the pipeline's own tests.
1549                let crate::indexer::object_version_by_checkpoint::Row::Change {
1550                    id,
1551                    checkpoint,
1552                    version,
1553                } = row
1554                else {
1555                    continue;
1556                };
1557                let (k, v) = object_version_by_checkpoint::store(id, checkpoint, version);
1558                batch
1559                    .put(&schema.object_version_by_checkpoint, &k, &v)
1560                    .unwrap();
1561            }
1562            batch.commit().unwrap();
1563        }
1564
1565        // Precondition: obj0 resolves at both checkpoints.
1566        assert_eq!(
1567            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
1568            Some(v_a),
1569        );
1570        assert_eq!(
1571            schema.get_object_version_at_checkpoint(obj0, 1).unwrap(),
1572            Some(v_b),
1573        );
1574
1575        let metrics = PrunerMetrics::new(None, &Registry::new());
1576
1577        // Prune checkpoint 0 only: tx0 creates obj0 and supersedes
1578        // nothing, so the cp0-pinned entry survives.
1579        let after_first = prune_chunk(&db, &schema, Watermarks::default(), 1, &metrics).unwrap();
1580        assert_eq!(
1581            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
1582            Some(v_a),
1583            "cp0-pinned entry must survive while its superseding tx is retained",
1584        );
1585
1586        // Prune checkpoint 1: tx1 supersedes obj0@v_a, retracting the
1587        // cp0-pinned entry; the cp1-pinned floor entry remains.
1588        prune_chunk(&db, &schema, after_first, 2, &metrics).unwrap();
1589        assert_eq!(
1590            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
1591            None,
1592            "cp0-pinned entry must be retracted once its superseding tx is pruned",
1593        );
1594        assert_eq!(
1595            schema.get_object_version_at_checkpoint(obj0, 1).unwrap(),
1596            Some(v_b),
1597            "cp1-pinned floor entry must be preserved",
1598        );
1599    }
1600
1601    /// The embedded entry point retracts `object_version_by_checkpoint`
1602    /// effects-driven, matching the standalone `prune_chunk`: a superseded
1603    /// object keeps only its latest sub-floor row (the anchor), while a
1604    /// removed object's rows are dropped entirely.
1605    #[test]
1606    fn prune_history_cohort_retracts_object_version_by_checkpoint() {
1607        let (_dir, db, schema) = fresh_db();
1608
1609        // Real checkpoints, built only for their effects: cp0 creates obj0
1610        // and obj1; cp1 transfers obj0 (supersedes it) and deletes obj1.
1611        let mut builder = TestCheckpointBuilder::new(0)
1612            .start_transaction(0)
1613            .create_owned_object(0)
1614            .create_owned_object(1)
1615            .finish_transaction();
1616        let cp0 = Arc::new(builder.build_checkpoint());
1617        builder = builder
1618            .start_transaction(0)
1619            .transfer_object(0, 1)
1620            .finish_transaction()
1621            .start_transaction(0)
1622            .delete_object(1)
1623            .finish_transaction();
1624        let cp1 = Arc::new(builder.build_checkpoint());
1625
1626        let obj0 = TestCheckpointBuilder::derive_object_id(0);
1627        let obj1 = TestCheckpointBuilder::derive_object_id(1);
1628
1629        // Seed the checkpoint-pinned rows directly (values are immaterial to
1630        // the retraction): obj0 changed in cp0 and cp1; obj1 was created in
1631        // cp0 and tombstoned in cp1.
1632        let ver = |n: u64| sui_types::base_types::SequenceNumber::from_u64(n);
1633        let mut batch = db.batch();
1634        for (id, checkpoint, version) in [(obj0, 0, 1), (obj0, 1, 2), (obj1, 0, 1), (obj1, 1, 2)] {
1635            let (k, v) = object_version_by_checkpoint::store(id, checkpoint, ver(version));
1636            batch
1637                .put(&schema.object_version_by_checkpoint, &k, &v)
1638                .unwrap();
1639        }
1640        batch.commit().unwrap();
1641
1642        // Precondition: both objects resolve at their creation checkpoint.
1643        assert_eq!(
1644            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
1645            Some(ver(1)),
1646        );
1647        assert_eq!(
1648            schema.get_object_version_at_checkpoint(obj1, 0).unwrap(),
1649            Some(ver(1)),
1650        );
1651
1652        // Prune through checkpoint 1 (new floor 2), feeding the pruned
1653        // checkpoints' effects tagged with the checkpoint each came from.
1654        // `pruned_tx_seq_exclusive` is 0 here: the tx-keyed CFs are empty in
1655        // this test, and the checkpoint floor advancing alone is enough.
1656        let effects: Vec<(u64, TransactionEffects)> = cp0
1657            .transactions
1658            .iter()
1659            .map(|tx| (0u64, tx.effects.clone()))
1660            .chain(cp1.transactions.iter().map(|tx| (1u64, tx.effects.clone())))
1661            .collect();
1662        prune_history_cohort(&db, &schema, 1, 0, &effects).unwrap();
1663
1664        // obj0 was superseded in cp1: its cp0 row is retracted, the cp1
1665        // anchor survives to resolve reads at or above the floor.
1666        assert_eq!(
1667            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
1668            None,
1669            "a superseded object's pre-anchor row must be retracted",
1670        );
1671        assert_eq!(
1672            schema.get_object_version_at_checkpoint(obj0, 2).unwrap(),
1673            Some(ver(2)),
1674            "the anchor a read at the floor resolves to must survive",
1675        );
1676
1677        // obj1 was removed in cp1: all its sub-floor rows are dropped.
1678        assert_eq!(
1679            schema.get_object_version_at_checkpoint(obj1, 2).unwrap(),
1680            None,
1681            "a removed object's rows must be dropped entirely",
1682        );
1683
1684        // The floor advanced to the new lowest-available checkpoint.
1685        assert_eq!(
1686            schema
1687                .get_pruning_watermarks()
1688                .unwrap()
1689                .unwrap()
1690                .checkpoint_lo,
1691            2,
1692        );
1693    }
1694
1695    /// `prune_history_cohort` (the embedded entry point) range-deletes
1696    /// `tx_metadata_by_seq`, point-deletes `tx_seq_by_digest` for the
1697    /// pruned digests, and advances the persisted floor — all from the
1698    /// floor the perpetual pruner supplies, without touching any raw
1699    /// chain-data CF.
1700    #[test]
1701    fn prune_history_cohort_deletes_tx_metadata_and_advances_floor() {
1702        use sui_types::digests::TransactionDigest;
1703
1704        use crate::schema::tx_metadata_by_seq;
1705
1706        let (_dir, db, schema) = fresh_db();
1707
1708        // Six transactions, tx_seq 0..6, each with a metadata row and a
1709        // digest -> tx_seq reverse-index entry.
1710        let digests: Vec<TransactionDigest> =
1711            (0u8..6).map(|i| TransactionDigest::new([i; 32])).collect();
1712        let mut batch = db.batch();
1713        for (tx_seq, digest) in digests.iter().enumerate() {
1714            let tx_seq = tx_seq as u64;
1715            batch
1716                .put(
1717                    &schema.tx_metadata_by_seq,
1718                    &U64Be(tx_seq),
1719                    &tx_metadata_by_seq::store(&tx_metadata_by_seq::Metadata {
1720                        digest: *digest,
1721                        checkpoint_seq: tx_seq,
1722                        ckpt_position: 0,
1723                        event_count: 0,
1724                        timestamp_ms: 0,
1725                    }),
1726                )
1727                .unwrap();
1728            batch
1729                .put(
1730                    &schema.tx_seq_by_digest,
1731                    &tx_seq_by_digest::Key(*digest),
1732                    &U64Varint(tx_seq),
1733                )
1734                .unwrap();
1735        }
1736        batch.commit().unwrap();
1737
1738        // Perpetual store has pruned through checkpoint 2; tx_seq 3 is
1739        // the first still-retained transaction.
1740        prune_history_cohort(&db, &schema, 2, 3, &[]).unwrap();
1741
1742        // tx_metadata 0..3 pruned, 3..6 retained.
1743        for tx_seq in 0..3 {
1744            assert!(
1745                schema.get_tx_metadata_by_seq(tx_seq).unwrap().is_none(),
1746                "tx_metadata {tx_seq} should be pruned",
1747            );
1748        }
1749        for tx_seq in 3..6 {
1750            assert!(
1751                schema.get_tx_metadata_by_seq(tx_seq).unwrap().is_some(),
1752                "tx_metadata {tx_seq} should be retained",
1753            );
1754        }
1755
1756        // Digest reverse index unindexed for the pruned range only.
1757        for digest in &digests[0..3] {
1758            assert!(schema.get_tx_seq_by_digest(digest).unwrap().is_none());
1759        }
1760        for digest in &digests[3..6] {
1761            assert!(schema.get_tx_seq_by_digest(digest).unwrap().is_some());
1762        }
1763
1764        // Floor advanced: tx_seq 3 and checkpoint 3 (= pruned 2 + 1).
1765        assert_eq!(
1766            schema.get_pruning_watermarks().unwrap(),
1767            Some(Watermarks {
1768                tx_seq_lo: 3,
1769                checkpoint_lo: 3,
1770            }),
1771        );
1772
1773        // Idempotent: a re-run at the same floor is a no-op.
1774        prune_history_cohort(&db, &schema, 2, 3, &[]).unwrap();
1775        assert_eq!(
1776            schema.get_pruning_watermarks().unwrap(),
1777            Some(Watermarks {
1778                tx_seq_lo: 3,
1779                checkpoint_lo: 3,
1780            }),
1781        );
1782    }
1783
1784    /// `prune_history_cohort` visits only the rows that exist when the
1785    /// floor is unknown (no prior watermark, so `tx_lo == 0`) and the
1786    /// `tx_seq` range is sparse with large gaps — it must not walk every
1787    /// integer in the interval.
1788    #[test]
1789    fn prune_history_cohort_handles_sparse_tx_seqs() {
1790        use sui_types::digests::TransactionDigest;
1791
1792        use crate::schema::tx_metadata_by_seq;
1793
1794        let (_dir, db, schema) = fresh_db();
1795
1796        // Three rows spread across a wide interval.
1797        let entries = [
1798            (0u64, [10u8; 32]),
1799            (500_000u64, [11u8; 32]),
1800            (999_999u64, [12u8; 32]),
1801        ];
1802        let mut batch = db.batch();
1803        for (tx_seq, digest_bytes) in entries {
1804            let digest = TransactionDigest::new(digest_bytes);
1805            batch
1806                .put(
1807                    &schema.tx_metadata_by_seq,
1808                    &U64Be(tx_seq),
1809                    &tx_metadata_by_seq::store(&tx_metadata_by_seq::Metadata {
1810                        digest,
1811                        checkpoint_seq: tx_seq,
1812                        ckpt_position: 0,
1813                        event_count: 0,
1814                        timestamp_ms: 0,
1815                    }),
1816                )
1817                .unwrap();
1818            batch
1819                .put(
1820                    &schema.tx_seq_by_digest,
1821                    &tx_seq_by_digest::Key(digest),
1822                    &U64Varint(tx_seq),
1823                )
1824                .unwrap();
1825        }
1826        batch.commit().unwrap();
1827
1828        // No prior pruning watermark (floor unknown -> 0); prune through
1829        // checkpoint 0 / tx_seq 600_000 exclusive. Only the two rows
1830        // below 600_000 are unindexed; the one at 999_999 survives.
1831        prune_history_cohort(&db, &schema, 0, 600_000, &[]).unwrap();
1832
1833        assert!(schema.get_tx_metadata_by_seq(0).unwrap().is_none());
1834        assert!(schema.get_tx_metadata_by_seq(500_000).unwrap().is_none());
1835        assert!(schema.get_tx_metadata_by_seq(999_999).unwrap().is_some());
1836        assert!(
1837            schema
1838                .get_tx_seq_by_digest(&TransactionDigest::new([10u8; 32]))
1839                .unwrap()
1840                .is_none()
1841        );
1842        assert!(
1843            schema
1844                .get_tx_seq_by_digest(&TransactionDigest::new([11u8; 32]))
1845                .unwrap()
1846                .is_none()
1847        );
1848        assert!(
1849            schema
1850                .get_tx_seq_by_digest(&TransactionDigest::new([12u8; 32]))
1851                .unwrap()
1852                .is_some()
1853        );
1854        assert_eq!(
1855            schema.get_pruning_watermarks().unwrap(),
1856            Some(Watermarks {
1857                tx_seq_lo: 600_000,
1858                checkpoint_lo: 1,
1859            }),
1860        );
1861    }
1862}