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). A best-effort per-object cursor
42//!   starts each scan at the object's previous retraction checkpoint; on
43//!   cache miss, eviction, or restart the scan falls back to checkpoint 0,
44//!   slower but deleting the same rows. Retractions use point deletes
45//!   because a fallback range delete would restart at checkpoint 0:
46//!   repeated same-start ranges nest, and RocksDB fragments `K` nested
47//!   range tombstones into `O(K^2)` pieces — which OOMed mainnet
48//!   fullnodes (see `retract_object_version_by_checkpoint`).
49//!   The retained set mirrors the `objects` versions kept, so the index
50//!   never points at a pruned version.
51//! - **Ledger-history bitmaps** (`transaction_bitmap`,
52//!   `event_bitmap`) — not deleted directly; advancing the
53//!   database-local pruning floor lets their compaction filters drop
54//!   fully-pruned buckets. Merge operands can require one covering
55//!   compaction to materialize and a later compaction to filter; the
56//!   forced catch-up pass and periodic compaction provide those sweeps.
57//!
58//! The live-set-bounded indexes (`object_by_owner`, `object_by_type`,
59//! `balance`, `package_versions`) and the tiny `epochs` CF are never
60//! pruned.
61//!
62//! # Floor, retention, and safety
63//!
64//! Retention is epoch-based: the `retention_epochs` most-recent
65//! epochs are retained, and the target floor is the start checkpoint
66//! of the oldest retained epoch. The floor is then clamped so it
67//! never advances past the oldest in-memory snapshot's checkpoint:
68//! point and range deletes are already invisible to a snapshot
69//! (RocksDB pins the data a live snapshot references), but the bitmap
70//! compaction filter physically removes buckets irrespective of
71//! snapshots, so the clamp keeps every live snapshot's advertised
72//! available range valid even under an aggressively small retention.
73//!
74//! Each tick advances the floor toward that target by at most
75//! `max_checkpoints_per_tick` checkpoints (in `max_chunk_checkpoints`
76//! atomic chunks), so a large backlog — for example when pruning is
77//! first enabled on an old database — drains across many ticks rather
78//! than one long blocking pass. The floor converges to the target
79//! over subsequent ticks.
80//!
81//! # Ordering and crash-safety
82//!
83//! Each chunk stages all of its deletes plus the new
84//! `PruningWatermarks` row into one atomic batch, commits, and only
85//! then advances the in-memory bitmap floor. Because the watermark
86//! row lives in the same batch as the deletes, a crash either loses
87//! the whole chunk (re-pruned next run) or commits it wholesale;
88//! there is no partial-delete-without-watermark state. Range and
89//! point deletes are idempotent, so a re-run is harmless.
90
91use std::collections::HashMap;
92use std::num::NonZeroUsize;
93use std::ops::Bound;
94use std::sync::Arc;
95use std::sync::Mutex;
96
97use lru::LruCache;
98
99use anyhow::Context as _;
100use prometheus::IntCounter;
101use prometheus::IntGauge;
102use prometheus::Registry;
103use prometheus::register_int_counter_with_registry;
104use prometheus::register_int_gauge_with_registry;
105use sui_consistent_store::Batch;
106use sui_consistent_store::Db;
107use sui_consistent_store::FrameworkSchema;
108use sui_consistent_store::PipelineTaskKey;
109use sui_indexer_alt_framework::service::Service;
110use sui_types::base_types::ObjectID;
111use sui_types::effects::TransactionEffects;
112use sui_types::effects::TransactionEffectsAPI;
113use sui_types::message_envelope::Message;
114use tokio::time::MissedTickBehavior;
115use tracing::debug;
116use tracing::info;
117use tracing::warn;
118
119use crate::RpcStoreSchema;
120use crate::config::PrunerConfig;
121use crate::indexer::Store;
122use crate::indexer::restore::HISTORY_COHORT;
123use crate::indexer::restore::LIVE_COHORT;
124use crate::schema::checkpoint_seq_by_digest;
125use crate::schema::event_bitmap;
126use crate::schema::object_version_by_checkpoint;
127use crate::schema::objects;
128use crate::schema::primitives::U64Be;
129use crate::schema::pruning_watermark;
130use crate::schema::pruning_watermark::Watermarks;
131use crate::schema::transaction_bitmap;
132use crate::schema::tx_seq_by_digest;
133
134/// Prometheus metrics for the pruner.
135pub struct PrunerMetrics {
136    /// Lowest still-available checkpoint sequence number — the
137    /// persisted checkpoint floor.
138    pub checkpoint_lo: IntGauge,
139    /// Lowest still-available transaction sequence number — the
140    /// persisted `tx_seq` floor.
141    pub tx_seq_lo: IntGauge,
142    /// Total pruning chunks committed.
143    pub chunks_committed: IntCounter,
144    /// Total superseded object versions and tombstones deleted.
145    pub objects_deleted: IntCounter,
146}
147
148impl PrunerMetrics {
149    pub fn new(prefix: Option<&str>, registry: &Registry) -> Arc<Self> {
150        let prefix = prefix.unwrap_or("rpc_store_pruner");
151        let name = |n| format!("{prefix}_{n}");
152
153        Arc::new(Self {
154            checkpoint_lo: register_int_gauge_with_registry!(
155                name("checkpoint_lo"),
156                "Lowest still-available checkpoint sequence number (pruning floor)",
157                registry,
158            )
159            .unwrap(),
160            tx_seq_lo: register_int_gauge_with_registry!(
161                name("tx_seq_lo"),
162                "Lowest still-available transaction sequence number (pruning floor)",
163                registry,
164            )
165            .unwrap(),
166            chunks_committed: register_int_counter_with_registry!(
167                name("chunks_committed"),
168                "Total pruning chunks committed",
169                registry,
170            )
171            .unwrap(),
172            objects_deleted: register_int_counter_with_registry!(
173                name("objects_deleted"),
174                "Total superseded object versions and tombstones deleted by the pruner",
175                registry,
176            )
177            .unwrap(),
178        })
179    }
180}
181
182/// Default maximum number of per-object retraction cursors retained in memory.
183pub const DEFAULT_RETRACTION_CURSORS_CAPACITY: usize = 200_000;
184
185/// In-memory cache of the greatest committed retraction checkpoint per object.
186///
187/// Bounded by an LRU policy with capacity [`DEFAULT_RETRACTION_CURSORS_CAPACITY`].
188/// A missing or evicted object falls back to a lower bound of `0`.
189#[derive(Debug)]
190pub struct RetractionCursors {
191    cache: LruCache<ObjectID, u64>,
192}
193
194impl Default for RetractionCursors {
195    fn default() -> Self {
196        Self::with_capacity(DEFAULT_RETRACTION_CURSORS_CAPACITY)
197    }
198}
199
200impl RetractionCursors {
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    pub fn with_capacity(capacity: usize) -> Self {
206        let capacity = NonZeroUsize::new(capacity.max(1)).expect("capacity is at least 1");
207        Self {
208            cache: LruCache::new(capacity),
209        }
210    }
211
212    /// Return the lower bound checkpoint for scanning an object's prefix.
213    /// Returns 0 on miss or eviction.
214    pub fn lower_bound(&self, id: &ObjectID) -> u64 {
215        self.cache.peek(id).copied().unwrap_or(0)
216    }
217
218    /// Monotonically advance the retraction checkpoint for the given object.
219    pub fn advance(&mut self, id: ObjectID, cp: u64) {
220        if let Some(existing) = self.cache.get_mut(&id) {
221            *existing = (*existing).max(cp);
222        } else {
223            self.cache.put(id, cp);
224        }
225    }
226
227    pub fn len(&self) -> usize {
228        self.cache.len()
229    }
230
231    pub fn is_empty(&self) -> bool {
232        self.cache.is_empty()
233    }
234
235    pub fn cap(&self) -> usize {
236        self.cache.cap().get()
237    }
238}
239
240/// Collects `object_version_by_checkpoint` retractions for one prune batch,
241/// coalescing every retraction for an object to a single entry.
242///
243/// Hot objects such as Clock and SuiSystemState can be superseded in every
244/// checkpoint, so one prune batch retracts the same object many times. The
245/// retraction point-deletes each checkpoint-pinned row below the superseding
246/// checkpoint by walking the object's prefix once (see
247/// [`retract_object_version_by_checkpoint`]); coalescing keeps a hot object's
248/// prefix from being walked once per supersession.
249///
250/// The retraction uses point deletes rather than one
251/// `delete_range [id||0, id||cp)` because cursor state is best-effort in
252/// memory: on cache miss, eviction, or node restart, the scan falls back to
253/// `lo_cp = 0`. If range deletes were used, successive retractions falling
254/// back to `id||0` would share that lower bound, causing range tombstones to
255/// nest and RocksDB's `FragmentedRangeTombstoneList` to fragment `K` of them
256/// into `K^2 / 2` `(fragment, seqnum)` pairs -- which OOMed mainnet fullnodes
257/// during memtable flush and WAL recovery. Point deletes are the only
258/// fallback-safe delete shape.
259/// Coalescing keeps only the greatest checkpoint per object: its rows below that
260/// checkpoint are the union of every narrower retraction's rows, so one widest
261/// retraction subsumes them all. On an equal checkpoint, `removed` tracks whether
262/// the object is removed at the end of that checkpoint, so effects must be
263/// recorded in transaction order: a removal sets it and a later write clears it.
264#[derive(Default)]
265struct Retractions(HashMap<ObjectID, (u64, bool)>);
266
267impl Retractions {
268    fn record(&mut self, id: ObjectID, cp: u64, removed: bool) {
269        self.0
270            .entry(id)
271            .and_modify(|(recorded_cp, recorded_removed)| {
272                if cp > *recorded_cp {
273                    *recorded_cp = cp;
274                    *recorded_removed = removed;
275                } else if cp == *recorded_cp {
276                    *recorded_removed |= removed;
277                }
278            })
279            .or_insert((cp, removed));
280    }
281
282    /// Records that `id` was written in `cp`, undoing an earlier removal in `cp`.
283    fn record_written(&mut self, id: ObjectID, cp: u64) {
284        if let Some((recorded_cp, recorded_removed)) = self.0.get_mut(&id)
285            && *recorded_cp == cp
286        {
287            *recorded_removed = false;
288        }
289    }
290
291    fn stage(
292        &self,
293        batch: &mut Batch,
294        schema: &RpcStoreSchema,
295        cursors: &RetractionCursors,
296    ) -> anyhow::Result<()> {
297        for (&id, &(cp, removed)) in &self.0 {
298            let lo_cp = cursors.lower_bound(&id);
299            retract_object_version_by_checkpoint(batch, schema, id, lo_cp, cp, removed)?;
300        }
301        Ok(())
302    }
303
304    fn commit_to_cursors(self, cursors: &mut RetractionCursors) {
305        for (id, (cp, _removed)) in self.0 {
306            cursors.advance(id, cp);
307        }
308    }
309}
310
311/// Start the background pruner as a [`Service`].
312///
313/// Errors if `config.retention_epochs` is `0` (which would prune the
314/// current epoch). The returned service runs an infinite tick loop;
315/// it is aborted on graceful shutdown (each chunk is atomic, so an
316/// abort leaves the database consistent).
317pub fn start_pruner(
318    store: Store,
319    config: PrunerConfig,
320    metrics: Arc<PrunerMetrics>,
321) -> anyhow::Result<Service> {
322    anyhow::ensure!(
323        config.retention_epochs >= 1,
324        "PrunerConfig::retention_epochs must be >= 1; 0 would prune the current epoch",
325    );
326    anyhow::ensure!(
327        config.max_checkpoints_per_tick >= 1,
328        "PrunerConfig::max_checkpoints_per_tick must be >= 1; 0 would never make progress",
329    );
330
331    let cursors = Arc::new(Mutex::new(RetractionCursors::default()));
332
333    let service = Service::new().spawn_aborting(async move {
334        let mut ticker = tokio::time::interval(config.interval());
335        ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
336
337        loop {
338            ticker.tick().await;
339
340            let store = store.clone();
341            let config = config.clone();
342            let metrics = metrics.clone();
343            let cursors = cursors.clone();
344
345            // The pruner does blocking RocksDB iteration and writes;
346            // keep it off the async runtime threads.
347            let res = tokio::task::spawn_blocking(move || {
348                let mut guard = cursors.lock().unwrap_or_else(|p| p.into_inner());
349                prune_once(store.db(), store.schema(), &mut guard, &config, &metrics)
350            })
351            .await;
352
353            match res {
354                Ok(Ok(())) => {}
355                Ok(Err(e)) => {
356                    warn!("rpc-store pruner pass failed (will retry next interval): {e:#}")
357                }
358                Err(e) => {
359                    if e.is_panic() {
360                        std::panic::resume_unwind(e.into_panic());
361                    } else {
362                        panic!("rpc-store pruner task failed: {e}");
363                    }
364                }
365            }
366        }
367    });
368
369    Ok(service)
370}
371
372/// Run a single pruning pass: recompute the target floor and advance
373/// the persisted floor toward it one chunk at a time.
374fn prune_once(
375    db: &Db,
376    schema: &RpcStoreSchema,
377    retraction_cursors: &mut RetractionCursors,
378    config: &PrunerConfig,
379    metrics: &PrunerMetrics,
380) -> anyhow::Result<()> {
381    let Some(current_epoch) = current_committed_epoch(db)? else {
382        debug!("rpc-store pruner: no committed watermark yet; nothing to prune");
383        return Ok(());
384    };
385
386    let Some(retention_lo) =
387        retention_checkpoint_floor(schema, current_epoch, config.retention_epochs)?
388    else {
389        debug!(
390            current_epoch,
391            "rpc-store pruner: retention floor not yet reached; nothing to prune"
392        );
393        return Ok(());
394    };
395
396    // Never advance the floor past the oldest live snapshot.
397    let target_lo = clamp_to_snapshot(retention_lo, db.snapshot_range().map(|r| *r.start()));
398
399    let mut cursor = schema.get_pruning_watermarks()?.unwrap_or_default();
400    if target_lo <= cursor.checkpoint_lo {
401        debug!(
402            target_lo,
403            current_lo = cursor.checkpoint_lo,
404            "rpc-store pruner: floor already at or beyond target"
405        );
406        return Ok(());
407    }
408
409    // Bound the work done this tick: advance the floor by at most
410    // `max_checkpoints_per_tick` checkpoints so a large backlog drains
411    // across many ticks instead of one long blocking pass. The floor
412    // converges to `target_lo` over subsequent ticks.
413    let tick_target = target_lo.min(cursor.checkpoint_lo + config.max_checkpoints_per_tick);
414
415    info!(
416        from = cursor.checkpoint_lo,
417        to = tick_target,
418        target = target_lo,
419        current_epoch,
420        "rpc-store pruner: advancing floor"
421    );
422
423    while cursor.checkpoint_lo < tick_target {
424        let chunk_ckpt_hi = (cursor.checkpoint_lo + config.max_chunk_checkpoints).min(tick_target);
425        cursor = prune_chunk(
426            db,
427            schema,
428            retraction_cursors,
429            cursor,
430            chunk_ckpt_hi,
431            metrics,
432        )?;
433        metrics.checkpoint_lo.set(cursor.checkpoint_lo as i64);
434        metrics.tx_seq_lo.set(cursor.tx_seq_lo as i64);
435        metrics.chunks_committed.inc();
436    }
437
438    // A bitmap row written as a merge operand may need one covering
439    // compaction to materialize and another to be filtered. Force a
440    // pass after reaching the retention target; the bitmap CFs'
441    // periodic compaction policy supplies subsequent passes. While a
442    // backlog is draining, skip whole-CF compaction so it does not
443    // become the per-tick long pole.
444    if cursor.checkpoint_lo >= target_lo {
445        db.compact_range_cf(transaction_bitmap::NAME, None, None)
446            .context("Compacting transaction_bitmap after prune")?;
447        db.compact_range_cf(event_bitmap::NAME, None, None)
448            .context("Compacting event_bitmap after prune")?;
449    }
450
451    Ok(())
452}
453
454/// Prune one chunk of checkpoints `[cursor.checkpoint_lo,
455/// chunk_ckpt_hi)` and their transactions, returning the new floor.
456fn prune_chunk(
457    db: &Db,
458    schema: &RpcStoreSchema,
459    retraction_cursors: &mut RetractionCursors,
460    cursor: Watermarks,
461    chunk_ckpt_hi: u64,
462    metrics: &PrunerMetrics,
463) -> anyhow::Result<Watermarks> {
464    let ckpt_lo = cursor.checkpoint_lo;
465    let tx_lo = cursor.tx_seq_lo;
466
467    // The exclusive `tx_seq` upper bound for the chunk is the
468    // cumulative network tx count after the chunk's highest
469    // checkpoint, which is the first `tx_seq` of `chunk_ckpt_hi`.
470    // `chunk_ckpt_hi >= 1` by the caller's loop invariant, and
471    // `chunk_ckpt_hi - 1 >= ckpt_lo` is still retained (not yet
472    // pruned), so its summary is present.
473    let last_ckpt = chunk_ckpt_hi - 1;
474    let tx_hi = schema
475        .get_checkpoint_summary(last_ckpt)?
476        .with_context(|| format!("checkpoint_summary missing for checkpoint {last_ckpt}"))?
477        .data()
478        .network_total_transactions;
479
480    let mut batch = db.batch();
481    let mut retractions = Retractions::default();
482    let mut objects_deleted: u64 = 0;
483    // Walk each pruned checkpoint and the transactions it contains.
484    // Consecutive summaries' `network_total_transactions` partition
485    // `[tx_lo, tx_hi)` into per-checkpoint tx ranges, so the containing
486    // checkpoint of every transaction is known here -- it is exactly
487    // the `seq` being walked -- without a per-transaction metadata
488    // lookup. Each effects row yields the object versions to retract
489    // and the transaction digest to unindex; a missing effects row
490    // means that transaction was already pruned (idempotent re-run).
491    let mut tx_cursor = tx_lo;
492    for seq in ckpt_lo..chunk_ckpt_hi {
493        // Every in-range summary is still present: the chunk has not
494        // deleted any yet, and prior chunks committed atomically. A
495        // miss is therefore corruption, not an expected re-run state,
496        // so fail loudly rather than mis-partition the tx range.
497        let summary = schema
498            .get_checkpoint_summary(seq)?
499            .with_context(|| format!("checkpoint_summary missing for checkpoint {seq}"))?;
500        let ckpt_tx_hi = summary.data().network_total_transactions;
501
502        for tx_seq in tx_cursor..ckpt_tx_hi {
503            let Some((effects, _unchanged)) = schema.get_effects(tx_seq)? else {
504                continue;
505            };
506            for (id, version) in effects.modified_at_versions() {
507                batch.delete(&schema.objects, &objects::Key { id, version })?;
508                // Record checkpoint-pinned entries older than this
509                // supersession for per-batch retraction; the entry at
510                // `seq` (the object's final version in this checkpoint)
511                // is kept.
512                retractions.record(id, seq, false);
513                objects_deleted += 1;
514            }
515            for (id, version) in effects.all_tombstones() {
516                batch.delete(&schema.objects, &objects::Key { id, version })?;
517                // The object was removed in `seq`: record that its
518                // tombstone entry at `seq` must be dropped too.
519                retractions.record(id, seq, true);
520                objects_deleted += 1;
521            }
522            for (oref, _, _) in effects.all_changed_objects() {
523                retractions.record_written(oref.0, seq);
524            }
525            batch.delete(
526                &schema.tx_seq_by_digest,
527                &tx_seq_by_digest::Key(*effects.transaction_digest()),
528            )?;
529        }
530        tx_cursor = ckpt_tx_hi;
531
532        // Unindex this checkpoint's digest reverse map.
533        batch.delete(
534            &schema.checkpoint_seq_by_digest,
535            &checkpoint_seq_by_digest::Key(summary.data().digest()),
536        )?;
537    }
538
539    // The `tx_seq`- and checkpoint-keyed CFs are contiguous, so one
540    // range delete each clears the whole chunk regardless of how many
541    // rows it spans.
542    batch.delete_range(&schema.transactions, &U64Be(tx_lo), &U64Be(tx_hi))?;
543    batch.delete_range(&schema.effects, &U64Be(tx_lo), &U64Be(tx_hi))?;
544    batch.delete_range(&schema.events, &U64Be(tx_lo), &U64Be(tx_hi))?;
545    batch.delete_range(&schema.tx_metadata_by_seq, &U64Be(tx_lo), &U64Be(tx_hi))?;
546    batch.delete_range(
547        &schema.checkpoint_summary,
548        &U64Be(ckpt_lo),
549        &U64Be(chunk_ckpt_hi),
550    )?;
551    batch.delete_range(
552        &schema.checkpoint_contents,
553        &U64Be(ckpt_lo),
554        &U64Be(chunk_ckpt_hi),
555    )?;
556
557    retractions.stage(&mut batch, schema, retraction_cursors)?;
558
559    // Advance the persisted floor atomically with the deletes.
560    let new = Watermarks {
561        tx_seq_lo: tx_hi,
562        checkpoint_lo: chunk_ckpt_hi,
563    };
564    let (k, v) = pruning_watermark::store(&new);
565    batch.put(&schema.pruning_watermark, &k, &v)?;
566
567    batch.commit()?;
568
569    retractions.commit_to_cursors(retraction_cursors);
570
571    // The commit is durable; advance the in-memory bitmap floor so
572    // the compaction filters drop buckets below `tx_hi`.
573    schema.set_pruning_floor(new.tx_seq_lo);
574    metrics.objects_deleted.inc_by(objects_deleted);
575
576    Ok(new)
577}
578
579/// Retract `object_version_by_checkpoint` rows for one object, given the
580/// greatest checkpoint in a prune batch that superseded or removed it, in
581/// lockstep with the `objects` CF.
582///
583/// Point-deletes every checkpoint-pinned entry for `id` strictly older than
584/// `cp` by walking the object's own prefix over `[id||lo_cp, id||cp)` and issuing a
585/// targeted delete for each row present. When `lo_cp >= cp`, the scan range is
586/// empty. The bounds stay within `id`'s prefix, so the scan never spills into the
587/// neighboring object. Callers coalesce repeated supersessions for the same
588/// object within a batch before calling this helper (see [`Retractions`]), so this
589/// prefix is walked once, at the greatest `cp`, whose row set is the union of
590/// every narrower retraction's -- including any removal below `cp`.
591///
592/// Invariant: after a retraction at checkpoint `lo_cp` commits, no live
593/// `object_version_by_checkpoint` row for that object exists strictly below
594/// `lo_cp` -- prior retractions visited and deleted every row below `lo_cp`,
595/// leaving at most the kept anchor exactly at `lo_cp` (or no row if removed at
596/// `lo_cp`). Therefore, a subsequent retraction at `cp > lo_cp` only needs to scan
597/// `[id||lo_cp, id||cp)`. Seeking to `id||lo_cp` skips the dead range below `lo_cp`.
598/// If `lo_cp` is 0 (cursor cache miss or eviction), the scan safely defaults to
599/// `[id||0, id||cp)`.
600///
601/// Point deletes rather than `delete_range`: cursor state is best-effort in
602/// memory, so on cache miss, eviction, or node restart, `lo_cp` falls back to
603/// `0`. If range deletes were used, successive retractions falling back to
604/// `id||0` would share that start and nest into `O(K^2)` `(fragment, seqnum)`
605/// pairs at flush, compaction, and read. Point deletes are the only
606/// fallback-safe delete shape: ordinary point tombstones carry no such
607/// structure, and the cost is one entry per deleted row and a bounded prefix
608/// scan on the delete path.
609///
610/// Once the floor advances past `cp`, the entry at `cp` (or a newer one) is the
611/// floor a checkpoint-pinned read resolves to, so the older entries can never
612/// be the answer again. Because the chunk only prunes checkpoints below the new
613/// floor, `cp` is itself below the floor, so the kept entry is never the answer
614/// to an in-range read either; it survives only until its own superseding
615/// transaction is pruned in a later chunk.
616///
617/// The entry *at* `cp` is kept for a supersession (it is the object's final
618/// live version in `cp`). When `removed` is set, the object was deleted or
619/// wrapped in `cp`: its tombstone entry at `cp` is dropped too, since nothing
620/// at or after the floor can reference a removed object.
621fn retract_object_version_by_checkpoint(
622    batch: &mut Batch,
623    schema: &RpcStoreSchema,
624    id: ObjectID,
625    lo_cp: u64,
626    cp: u64,
627    removed: bool,
628) -> anyhow::Result<()> {
629    if lo_cp < cp {
630        let lo = object_version_by_checkpoint::Key {
631            id,
632            checkpoint: lo_cp,
633        };
634        let hi = object_version_by_checkpoint::Key { id, checkpoint: cp };
635        for entry in schema
636            .object_version_by_checkpoint
637            .iter((Bound::Included(lo), Bound::Excluded(hi)))?
638        {
639            let (key, _value) = entry?;
640            batch.delete(&schema.object_version_by_checkpoint, &key)?;
641        }
642    }
643    if removed {
644        let hi = object_version_by_checkpoint::Key { id, checkpoint: cp };
645        batch.delete(&schema.object_version_by_checkpoint, &hi)?;
646    }
647    Ok(())
648}
649
650/// Prune the embedded fullnode's history cohort up to a floor supplied
651/// by the validator's perpetual-store pruner.
652///
653/// Unlike [`start_pruner`], this is not epoch-driven and not a
654/// `Service`. The embedded deployment deactivates the raw chain-data
655/// CFs (`transactions`, `effects`, `events`, `objects`,
656/// `checkpoint_*`), so it cannot derive a retention floor or read the
657/// raw effects itself. Instead the perpetual pruner — which owns the raw
658/// data — supplies the floor and the pruned checkpoints' `effects`
659/// directly, and this prunes exactly the history-cohort CFs that grow
660/// without bound:
661///
662/// - `tx_metadata_by_seq` — range-deleted over
663///   `[old_tx_lo, pruned_tx_seq_exclusive)`.
664/// - `tx_seq_by_digest` — point-deleted; the digests are read from
665///   `tx_metadata_by_seq` (the only history CF that still carries them)
666///   over the pruned range, before that range is deleted.
667/// - `object_version_by_checkpoint` — retracted effects-driven through the
668///   same per-batch deduped retraction path as the standalone `prune_chunk`
669///   (the paired `objects` delete lives in that caller, not the helper, and
670///   the embedded store has no `objects` CF): each effect carries the
671///   checkpoint it was pruned from, and repeated retractions for one object
672///   are coalesced to the greatest checkpoint, so a superseded object keeps
673///   only its supersession-checkpoint row — the anchor a point-in-time read at
674///   the floor resolves to — and a removed object drops its rows (a
675///   re-creation later in the same checkpoint, or at or above the floor,
676///   survives). `effects` must be in transaction order within a checkpoint.
677/// - `transaction_bitmap` / `event_bitmap` — evicted by advancing the
678///   database-local `tx_seq` floor so their compaction filters drop
679///   fully-pruned buckets during periodic compaction.
680///
681/// The live cohort, `package_versions`, and the tiny `epochs` CF are
682/// never pruned.
683///
684/// `pruned_checkpoint_watermark` is the highest checkpoint the
685/// perpetual store has pruned (inclusive); `pruned_tx_seq_exclusive` is
686/// the first still-retained `tx_seq`. The pruner consumes the same floor
687/// the perpetual store prunes to, so the embedded rpc-store's history
688/// cohort stays in lockstep with it. Idempotent: a re-run with the same
689/// or a lower floor is a no-op.
690///
691/// Ordering contract: the caller must invoke this BEFORE durably
692/// committing its own prune of the same checkpoints. The
693/// `object_version_by_checkpoint` retraction is driven by the `effects`
694/// passed in this call and is never re-derived; if the caller's floor
695/// committed first, a crash between the two commits would skip these
696/// effects forever and leak the rows they retract. Committing this side
697/// first is safe precisely because a re-run is idempotent.
698pub fn prune_history_cohort(
699    db: &Db,
700    schema: &RpcStoreSchema,
701    retraction_cursors: &mut RetractionCursors,
702    pruned_checkpoint_watermark: u64,
703    pruned_tx_seq_exclusive: u64,
704    effects: &[(u64, TransactionEffects)],
705) -> anyhow::Result<()> {
706    let cursor = schema.get_pruning_watermarks()?.unwrap_or_default();
707    let tx_lo = cursor.tx_seq_lo;
708    let tx_hi = pruned_tx_seq_exclusive;
709    // Lowest still-available checkpoint after this prune: the perpetual
710    // store has pruned through `pruned_checkpoint_watermark` inclusive.
711    let checkpoint_lo = pruned_checkpoint_watermark.saturating_add(1);
712
713    // No-op if the floor would not advance on either axis (idempotent
714    // re-run, or the perpetual floor is behind ours).
715    if tx_hi <= tx_lo && checkpoint_lo <= cursor.checkpoint_lo {
716        return Ok(());
717    }
718
719    let mut batch = db.batch();
720    let mut retractions = Retractions::default();
721    // Unindex the digest reverse map for the pruned `tx_seq` range. The
722    // digests live in `tx_metadata_by_seq`; iterate it (seeking to the
723    // first present row) rather than point-getting each `tx_seq`, so a
724    // sparse range or an unknown (zero) floor costs work proportional to
725    // the rows present, not to the width of the interval.
726    for entry in schema.iter_tx_seq_digests(tx_lo, tx_hi)? {
727        let (_tx_seq, digest) = entry?;
728        batch.delete(&schema.tx_seq_by_digest, &tx_seq_by_digest::Key(digest))?;
729    }
730    batch.delete_range(&schema.tx_metadata_by_seq, &U64Be(tx_lo), &U64Be(tx_hi))?;
731
732    // Retract `object_version_by_checkpoint` for every object the pruned
733    // checkpoints superseded or removed, reusing the same per-batch deduped
734    // effects-driven path as the standalone `prune_chunk` (its paired
735    // `objects` delete lives in that caller, not the helper, and the embedded
736    // store has no `objects` CF). Each effect carries the checkpoint it was
737    // pruned from, and repeated retractions for one object are coalesced to the
738    // greatest checkpoint, so the retraction keeps each object's anchor at its
739    // true latest supersession checkpoint and drops the older ones; a removed
740    // object drops its tombstone too, unless a later transaction in the same
741    // checkpoint writes it again.
742    for (checkpoint, effects) in effects {
743        for (id, _version) in effects.modified_at_versions() {
744            retractions.record(id, *checkpoint, false);
745        }
746        for (id, _version) in effects.all_tombstones() {
747            retractions.record(id, *checkpoint, true);
748        }
749        for (oref, _, _) in effects.all_changed_objects() {
750            retractions.record_written(oref.0, *checkpoint);
751        }
752    }
753    retractions.stage(&mut batch, schema, retraction_cursors)?;
754
755    // Advance the persisted floor atomically with the deletes, taking
756    // the monotonic max on each axis so a stale lower floor never
757    // regresses an axis the other call already advanced.
758    let new = Watermarks {
759        tx_seq_lo: tx_hi.max(tx_lo),
760        checkpoint_lo: checkpoint_lo.max(cursor.checkpoint_lo),
761    };
762    let (k, v) = pruning_watermark::store(&new);
763    batch.put(&schema.pruning_watermark, &k, &v)?;
764    batch.commit()?;
765
766    retractions.commit_to_cursors(retraction_cursors);
767
768    // Durable now: advance the in-memory bitmap floor so the bitmap
769    // compaction filters start dropping fully-pruned buckets on the next
770    // natural background compaction. The prune forces no sweep of its own:
771    // compacting on every prune batch is far more compaction work than the
772    // reclaimed space is worth.
773    schema.set_pruning_floor(new.tx_seq_lo);
774
775    Ok(())
776}
777
778/// The highest checkpoint the embedded fullnode's pruner may prune
779/// through (inclusive) without deleting source data the embedded
780/// indexer still needs: `min(checkpoint_hi_inclusive)` across every
781/// embedded-cohort pipeline ([`LIVE_COHORT`] and [`HISTORY_COHORT`]).
782///
783/// Both cohorts assemble full checkpoints from the perpetual and
784/// checkpoint stores through the local ingestion client — the history
785/// cohort while backfilling `(L, T]`, the live cohort when filling
786/// gaps behind the executor's broadcast stream — so a checkpoint's
787/// data may only be deleted once every pipeline has committed it.
788/// Pruning past a pipeline's watermark would leave that pipeline
789/// permanently stalled on a checkpoint that can no longer be served
790/// (`NotFound` is retried forever).
791///
792/// Returns `None` when any cohort pipeline has no watermark yet — a
793/// from-genesis build before that pipeline's first commit — in which
794/// case nothing may be pruned: the pipeline still needs the entire
795/// available range.
796///
797/// [`LIVE_COHORT`]: crate::LIVE_COHORT
798/// [`HISTORY_COHORT`]: crate::HISTORY_COHORT
799pub fn embedded_prunable_checkpoint(db: &Db) -> anyhow::Result<Option<u64>> {
800    let framework = db.framework();
801    let mut min_hi: Option<u64> = None;
802    for name in LIVE_COHORT.iter().chain(HISTORY_COHORT) {
803        let key = PipelineTaskKey::new(*name);
804        let Some(watermark) = framework
805            .watermarks
806            .get(&key)
807            .with_context(|| format!("reading watermark for {name}"))?
808        else {
809            return Ok(None);
810        };
811        let hi = watermark.checkpoint_hi_inclusive;
812        min_hi = Some(min_hi.map_or(hi, |m| m.min(hi)));
813    }
814    Ok(min_hi)
815}
816
817/// The lowest epoch fully committed across every registered pipeline,
818/// or `None` if no pipeline has committed a watermark yet.
819///
820/// Taking the minimum is deliberately conservative: it lags the true
821/// tip epoch by at most one epoch while a pipeline catches up across
822/// a boundary, which only ever causes the pruner to retain slightly
823/// more.
824fn current_committed_epoch(db: &Db) -> anyhow::Result<Option<u64>> {
825    let framework = FrameworkSchema::new(db.clone());
826    let mut min_epoch: Option<u64> = None;
827    for entry in framework.watermarks.iter(..)? {
828        let (_, watermark) = entry?;
829        let epoch = watermark.epoch_hi_inclusive;
830        min_epoch = Some(min_epoch.map_or(epoch, |m| m.min(epoch)));
831    }
832    Ok(min_epoch)
833}
834
835/// The target checkpoint floor implied by epoch-based retention: the
836/// start checkpoint of the oldest epoch that is still retained.
837///
838/// Returns `None` when nothing is eligible yet — either the chain is
839/// younger than the retention window, or the oldest retained epoch's
840/// row (or its `start_checkpoint`) has not been observed.
841fn retention_checkpoint_floor(
842    schema: &RpcStoreSchema,
843    current_epoch: u64,
844    retention_epochs: u64,
845) -> anyhow::Result<Option<u64>> {
846    debug_assert!(retention_epochs >= 1, "validated in start_pruner");
847
848    // Retain epochs `[oldest_retained, current_epoch]`.
849    let oldest_retained = current_epoch.saturating_sub(retention_epochs - 1);
850    if oldest_retained == 0 {
851        // Epoch 0 is still retained, so no epoch has fully aged out.
852        return Ok(None);
853    }
854
855    let Some(info) = schema.get_epoch(oldest_retained)? else {
856        return Ok(None);
857    };
858    Ok(info.start_checkpoint)
859}
860
861/// Clamp the retention-derived floor so it never advances past the
862/// oldest in-memory snapshot's checkpoint. With no snapshots the
863/// retention floor stands; otherwise the floor is held at or below
864/// the oldest snapshot so that snapshot's advertised available range
865/// stays valid (and the bitmap compaction filter, which ignores
866/// snapshots, never drops a bucket the snapshot still serves).
867fn clamp_to_snapshot(retention_lo: u64, oldest_snapshot: Option<u64>) -> u64 {
868    match oldest_snapshot {
869        Some(snap) => retention_lo.min(snap),
870        None => retention_lo,
871    }
872}
873
874#[cfg(test)]
875mod tests {
876    use std::sync::Arc;
877
878    use prometheus::Registry;
879    use sui_consistent_store::Db;
880    use sui_consistent_store::DbOptions;
881    use sui_consistent_store::PipelineTaskKey;
882    use sui_consistent_store::Watermark;
883    use sui_indexer_alt_framework::pipeline::Processor;
884    use sui_types::test_checkpoint_data_builder::TestCheckpointBuilder;
885
886    use super::*;
887    use crate::schema::epochs;
888    use crate::schema::primitives::U64Varint;
889
890    fn fresh_db() -> (tempfile::TempDir, Db, RpcStoreSchema) {
891        let dir = tempfile::tempdir().unwrap();
892        let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
893        (dir, db, schema)
894    }
895
896    /// Stamp `checkpoint_hi_inclusive = hi` watermarks for `names`.
897    fn stamp_watermarks(db: &Db, names: &[&str], hi: u64) {
898        let framework = FrameworkSchema::new(db.clone());
899        let mut batch = db.batch();
900        for name in names {
901            batch
902                .put(
903                    &framework.watermarks,
904                    &PipelineTaskKey::new(*name),
905                    &Watermark::for_checkpoint(hi),
906                )
907                .unwrap();
908        }
909        batch.commit().unwrap();
910    }
911
912    /// `embedded_prunable_checkpoint` is the minimum watermark across
913    /// both embedded cohorts, and `None` while any cohort pipeline has
914    /// no watermark at all.
915    #[test]
916    fn embedded_prunable_checkpoint_is_min_across_cohorts() {
917        let (_dir, db, _schema) = fresh_db();
918
919        // Fresh database: nothing committed, nothing prunable.
920        assert_eq!(embedded_prunable_checkpoint(&db).unwrap(), None);
921
922        // Live cohort at the tip, history cohort still absent (e.g. a
923        // from-genesis backfill before its first commit): still
924        // nothing prunable.
925        stamp_watermarks(&db, LIVE_COHORT, 1_000);
926        assert_eq!(embedded_prunable_checkpoint(&db).unwrap(), None);
927
928        // Every history pipeline committed through 40 except one
929        // straggler at 25: the straggler bounds the prunable range.
930        stamp_watermarks(&db, HISTORY_COHORT, 40);
931        stamp_watermarks(&db, &[HISTORY_COHORT[0]], 25);
932        assert_eq!(embedded_prunable_checkpoint(&db).unwrap(), Some(25));
933
934        // The straggler catches up past the live cohort: the live
935        // cohort's watermark now bounds the range.
936        stamp_watermarks(&db, HISTORY_COHORT, 2_000);
937        assert_eq!(embedded_prunable_checkpoint(&db).unwrap(), Some(1_000));
938    }
939
940    /// Populate the CFs the pruner reads and deletes by running the
941    /// real pipelines' `process` over `checkpoint` and staging their
942    /// rows — `objects`, `effects`, `checkpoint_summary`, and the two
943    /// digest reverse indexes. These cover both deletion mechanisms
944    /// (range delete and point delete) plus the effects-driven object
945    /// retraction.
946    async fn seed(
947        db: &Db,
948        schema: &RpcStoreSchema,
949        checkpoint: &Arc<sui_types::full_checkpoint_content::Checkpoint>,
950    ) {
951        let mut batch = db.batch();
952        for row in crate::indexer::objects::Objects
953            .process(checkpoint)
954            .await
955            .unwrap()
956        {
957            batch
958                .put(
959                    &schema.objects,
960                    &objects::Key {
961                        id: row.id,
962                        version: row.version,
963                    },
964                    &row.value,
965                )
966                .unwrap();
967        }
968        for row in crate::indexer::effects::Effects
969            .process(checkpoint)
970            .await
971            .unwrap()
972        {
973            batch
974                .put(&schema.effects, &U64Be(row.tx_seq), &row.value)
975                .unwrap();
976        }
977        for row in crate::indexer::checkpoint_summary::CheckpointSummary
978            .process(checkpoint)
979            .await
980            .unwrap()
981        {
982            batch
983                .put(&schema.checkpoint_summary, &U64Be(row.seq), &row.value)
984                .unwrap();
985        }
986        for row in crate::indexer::tx_seq_by_digest::TxSeqByDigest
987            .process(checkpoint)
988            .await
989            .unwrap()
990        {
991            batch
992                .put(
993                    &schema.tx_seq_by_digest,
994                    &tx_seq_by_digest::Key(row.digest),
995                    &U64Varint(row.tx_seq),
996                )
997                .unwrap();
998        }
999        for row in crate::indexer::checkpoint_seq_by_digest::CheckpointSeqByDigest
1000            .process(checkpoint)
1001            .await
1002            .unwrap()
1003        {
1004            batch
1005                .put(
1006                    &schema.checkpoint_seq_by_digest,
1007                    &checkpoint_seq_by_digest::Key(row.digest),
1008                    &U64Varint(row.seq),
1009                )
1010                .unwrap();
1011        }
1012        batch.commit().unwrap();
1013    }
1014
1015    /// Stage the `Change` rows the `object_version_by_checkpoint` pipeline
1016    /// produces for `checkpoint`.
1017    async fn seed_object_version_by_checkpoint(
1018        db: &Db,
1019        schema: &RpcStoreSchema,
1020        checkpoint: &Arc<sui_types::full_checkpoint_content::Checkpoint>,
1021    ) {
1022        use crate::indexer::object_version_by_checkpoint::ObjectVersionByCheckpoint;
1023        use crate::indexer::object_version_by_checkpoint::Row;
1024
1025        let mut batch = db.batch();
1026        for row in ObjectVersionByCheckpoint::default()
1027            .process(checkpoint)
1028            .await
1029            .unwrap()
1030        {
1031            let Row::Change {
1032                id,
1033                checkpoint,
1034                version,
1035            } = row
1036            else {
1037                continue;
1038            };
1039            let (k, v) = object_version_by_checkpoint::store(id, checkpoint, version);
1040            batch
1041                .put(&schema.object_version_by_checkpoint, &k, &v)
1042                .unwrap();
1043        }
1044        batch.commit().unwrap();
1045    }
1046
1047    /// cp0 creates obj0 and obj1. In cp1, separate transactions delete then
1048    /// re-create obj0, and wrap then unwrap obj1, so both are live at its end.
1049    fn removed_and_recreated_checkpoints() -> (
1050        Arc<sui_types::full_checkpoint_content::Checkpoint>,
1051        Arc<sui_types::full_checkpoint_content::Checkpoint>,
1052    ) {
1053        let mut builder = TestCheckpointBuilder::new(0)
1054            .start_transaction(0)
1055            .create_owned_object(0)
1056            .create_owned_object(1)
1057            .finish_transaction();
1058        let cp0 = Arc::new(builder.build_checkpoint());
1059        builder = builder
1060            .start_transaction(0)
1061            .delete_object(0)
1062            .finish_transaction()
1063            .start_transaction(0)
1064            .create_owned_object(0)
1065            .finish_transaction()
1066            .start_transaction(0)
1067            .wrap_object(1)
1068            .finish_transaction()
1069            .start_transaction(0)
1070            .unwrap_object(1)
1071            .finish_transaction();
1072        let cp1 = Arc::new(builder.build_checkpoint());
1073        (cp0, cp1)
1074    }
1075
1076    fn seed_checkpoint_versions(
1077        db: &Db,
1078        schema: &RpcStoreSchema,
1079        id: ObjectID,
1080        rows: &[(u64, u64)],
1081    ) {
1082        let mut batch = db.batch();
1083        for &(checkpoint, version) in rows {
1084            let (k, v) = object_version_by_checkpoint::store(
1085                id,
1086                checkpoint,
1087                sui_types::base_types::SequenceNumber::from_u64(version),
1088            );
1089            batch
1090                .put(&schema.object_version_by_checkpoint, &k, &v)
1091                .unwrap();
1092        }
1093        batch.commit().unwrap();
1094    }
1095
1096    #[test]
1097    fn retractions_stage_widest_range_per_object() {
1098        let (_dir, db, schema) = fresh_db();
1099        let obj = TestCheckpointBuilder::derive_object_id(0);
1100        seed_checkpoint_versions(&db, &schema, obj, &[(0, 1), (1, 2), (2, 3)]);
1101
1102        let mut retractions = Retractions::default();
1103        retractions.record(obj, 1, true);
1104        retractions.record(obj, 2, false);
1105        let mut batch = db.batch();
1106        retractions
1107            .stage(&mut batch, &schema, &RetractionCursors::default())
1108            .unwrap();
1109        batch.commit().unwrap();
1110
1111        assert_eq!(
1112            schema.get_object_version_at_checkpoint(obj, 1).unwrap(),
1113            None,
1114            "the widest range must cover lower-checkpoint removals",
1115        );
1116        assert_eq!(
1117            schema.get_object_version_at_checkpoint(obj, 2).unwrap(),
1118            Some(sui_types::base_types::SequenceNumber::from_u64(3)),
1119            "a lower removed=true retraction must not delete the latest anchor",
1120        );
1121    }
1122
1123    #[test]
1124    fn retractions_or_removed_on_equal_checkpoint() {
1125        let (_dir, db, schema) = fresh_db();
1126        let obj = TestCheckpointBuilder::derive_object_id(0);
1127        seed_checkpoint_versions(&db, &schema, obj, &[(0, 1), (2, 3)]);
1128
1129        let mut retractions = Retractions::default();
1130        retractions.record(obj, 2, false);
1131        retractions.record(obj, 2, true);
1132        let mut batch = db.batch();
1133        retractions
1134            .stage(&mut batch, &schema, &RetractionCursors::default())
1135            .unwrap();
1136        batch.commit().unwrap();
1137
1138        assert_eq!(
1139            schema.get_object_version_at_checkpoint(obj, 2).unwrap(),
1140            None,
1141            "same-checkpoint removals must drop the checkpoint row",
1142        );
1143    }
1144
1145    #[test]
1146    fn retractions_write_after_removal_keeps_checkpoint_row() {
1147        let (_dir, db, schema) = fresh_db();
1148        let obj = TestCheckpointBuilder::derive_object_id(0);
1149        seed_checkpoint_versions(&db, &schema, obj, &[(0, 1), (2, 3)]);
1150
1151        let mut retractions = Retractions::default();
1152        retractions.record(obj, 2, false);
1153        retractions.record(obj, 2, true);
1154        retractions.record_written(obj, 2);
1155        let mut batch = db.batch();
1156        retractions
1157            .stage(&mut batch, &schema, &RetractionCursors::default())
1158            .unwrap();
1159        batch.commit().unwrap();
1160
1161        assert_eq!(
1162            schema.get_object_version_at_checkpoint(obj, 0).unwrap(),
1163            None,
1164        );
1165        assert_eq!(
1166            schema.get_object_version_at_checkpoint(obj, 2).unwrap(),
1167            Some(sui_types::base_types::SequenceNumber::from_u64(3)),
1168            "a write after a same-checkpoint removal must keep the checkpoint row",
1169        );
1170    }
1171
1172    #[test]
1173    fn retractions_write_at_other_checkpoint_does_not_clear_removal() {
1174        let (_dir, db, schema) = fresh_db();
1175        let obj = TestCheckpointBuilder::derive_object_id(0);
1176        seed_checkpoint_versions(&db, &schema, obj, &[(0, 1), (2, 3)]);
1177
1178        let mut retractions = Retractions::default();
1179        retractions.record(obj, 2, true);
1180        retractions.record_written(obj, 1);
1181        let mut batch = db.batch();
1182        retractions
1183            .stage(&mut batch, &schema, &RetractionCursors::default())
1184            .unwrap();
1185        batch.commit().unwrap();
1186
1187        assert_eq!(
1188            schema.get_object_version_at_checkpoint(obj, 2).unwrap(),
1189            None,
1190        );
1191    }
1192
1193    /// A hot object with deep checkpoint-pinned history (the Clock /
1194    /// SuiSystemState shape, superseded in every checkpoint) is cleared below
1195    /// the coalesced retraction checkpoint in a single pass, keeping only the
1196    /// anchor at that checkpoint. Exercises the point-delete prefix walk that
1197    /// replaced the per-object range delete, and confirms it deletes exactly
1198    /// the rows in `[id||0, id||cp)` without spilling into the next object.
1199    #[test]
1200    fn retraction_point_deletes_deep_history() {
1201        let (_dir, db, schema) = fresh_db();
1202        let obj = TestCheckpointBuilder::derive_object_id(0);
1203        let neighbor = TestCheckpointBuilder::derive_object_id(1);
1204
1205        // The object changed in every checkpoint 0..1000 (version = cp + 1);
1206        // seed a neighboring object below the retraction floor to prove the
1207        // bounded scan does not cross the id boundary.
1208        let rows: Vec<(u64, u64)> = (0..1_000u64).map(|c| (c, c + 1)).collect();
1209        seed_checkpoint_versions(&db, &schema, obj, &rows);
1210        seed_checkpoint_versions(&db, &schema, neighbor, &[(10, 42)]);
1211
1212        // Superseded in every checkpoint: without coalescing this would walk
1213        // the prefix 1000 times; the collector reduces it to one retraction at
1214        // the greatest checkpoint (999).
1215        let mut retractions = Retractions::default();
1216        for (checkpoint, _) in &rows {
1217            retractions.record(obj, *checkpoint, false);
1218        }
1219        let mut batch = db.batch();
1220        retractions
1221            .stage(&mut batch, &schema, &RetractionCursors::default())
1222            .unwrap();
1223        batch.commit().unwrap();
1224
1225        // Everything below 999 is gone; the anchor at 999 survives as the floor
1226        // a point-in-time read resolves to.
1227        assert_eq!(
1228            schema.get_object_version_at_checkpoint(obj, 998).unwrap(),
1229            None,
1230            "history below the coalesced checkpoint must be fully point-deleted",
1231        );
1232        assert_eq!(
1233            schema.get_object_version_at_checkpoint(obj, 999).unwrap(),
1234            Some(sui_types::base_types::SequenceNumber::from_u64(1_000)),
1235            "the anchor at the coalesced checkpoint must survive",
1236        );
1237        let remaining: Vec<u64> = schema
1238            .iter_object_versions_by_checkpoint(obj)
1239            .unwrap()
1240            .map(|r| r.unwrap().0.checkpoint)
1241            .collect();
1242        assert_eq!(remaining, vec![999], "only the anchor row remains");
1243
1244        // The neighboring object's rows are untouched.
1245        assert_eq!(
1246            schema
1247                .get_object_version_at_checkpoint(neighbor, 10)
1248                .unwrap(),
1249            Some(sui_types::base_types::SequenceNumber::from_u64(42)),
1250            "the bounded scan must not delete a neighboring object's rows",
1251        );
1252    }
1253
1254    #[test]
1255    fn clamp_to_snapshot_holds_floor_at_or_below_oldest_snapshot() {
1256        // No snapshots: retention floor stands.
1257        assert_eq!(clamp_to_snapshot(100, None), 100);
1258        // Retention is well below the oldest snapshot: retention binds.
1259        assert_eq!(clamp_to_snapshot(100, Some(250)), 100);
1260        // Retention would overrun the oldest snapshot: clamp holds.
1261        assert_eq!(clamp_to_snapshot(300, Some(250)), 250);
1262        // Exactly at the oldest snapshot is allowed.
1263        assert_eq!(clamp_to_snapshot(250, Some(250)), 250);
1264    }
1265
1266    #[test]
1267    fn retention_floor_none_when_chain_younger_than_window() {
1268        let (_dir, _db, schema) = fresh_db();
1269        // current_epoch=2, retention=5 => oldest_retained saturates to
1270        // 0, so epoch 0 is still retained and nothing has aged out.
1271        assert!(retention_checkpoint_floor(&schema, 2, 5).unwrap().is_none());
1272    }
1273
1274    #[test]
1275    fn retention_floor_is_start_checkpoint_of_oldest_retained_epoch() {
1276        let (_dir, db, schema) = fresh_db();
1277        // Seed epoch 3's start record at checkpoint 300.
1278        let mut batch = db.batch();
1279        batch
1280            .merge(
1281                &schema.epochs,
1282                &U64Be(3),
1283                &epochs::start(1, 1, 0, Some(300), None),
1284            )
1285            .unwrap();
1286        batch.commit().unwrap();
1287        // current_epoch=5, retention=3 => retain [3, 5], oldest
1288        // retained is epoch 3, whose start checkpoint is the floor.
1289        assert_eq!(
1290            retention_checkpoint_floor(&schema, 5, 3).unwrap(),
1291            Some(300)
1292        );
1293    }
1294
1295    #[test]
1296    fn retention_floor_none_when_oldest_epoch_row_missing() {
1297        let (_dir, _db, schema) = fresh_db();
1298        // Oldest retained epoch is 9, but no row has been observed.
1299        assert!(
1300            retention_checkpoint_floor(&schema, 10, 2)
1301                .unwrap()
1302                .is_none()
1303        );
1304    }
1305
1306    #[test]
1307    fn current_committed_epoch_takes_min_across_watermarks() {
1308        let (_dir, db, _schema) = fresh_db();
1309        let framework = FrameworkSchema::new(db.clone());
1310        let mut batch = db.batch();
1311        batch
1312            .put(
1313                &framework.watermarks,
1314                &PipelineTaskKey::new("a"),
1315                &Watermark {
1316                    epoch_hi_inclusive: 7,
1317                    ..Default::default()
1318                },
1319            )
1320            .unwrap();
1321        batch
1322            .put(
1323                &framework.watermarks,
1324                &PipelineTaskKey::new("b"),
1325                &Watermark {
1326                    epoch_hi_inclusive: 5,
1327                    ..Default::default()
1328                },
1329            )
1330            .unwrap();
1331        batch.commit().unwrap();
1332        assert_eq!(current_committed_epoch(&db).unwrap(), Some(5));
1333    }
1334
1335    #[test]
1336    fn current_committed_epoch_none_when_no_watermarks() {
1337        let (_dir, db, _schema) = fresh_db();
1338        assert!(current_committed_epoch(&db).unwrap().is_none());
1339    }
1340
1341    /// Production-shaped bitmap reclamation: merge operands survive the
1342    /// first covering compaction that materializes them, then expired
1343    /// buckets are filtered on the second while retained buckets remain.
1344    #[test]
1345    fn merge_written_bitmap_buckets_require_two_compactions_for_reclamation() {
1346        let (_dir, db, schema) = fresh_db();
1347        let dimension = b"sender:alice".to_vec();
1348        let floor = transaction_bitmap::TX_BUCKET_SIZE;
1349        let retained_tx_seq = floor + 5;
1350
1351        let (tx_low_key, tx_low_value) = transaction_bitmap::store_match(dimension.clone(), 5);
1352        let (tx_high_key, tx_high_value) =
1353            transaction_bitmap::store_match(dimension.clone(), retained_tx_seq);
1354        let (event_low_key, event_low_value) = event_bitmap::store_match(dimension.clone(), 5, 0);
1355        let (event_high_key, event_high_value) =
1356            event_bitmap::store_match(dimension.clone(), retained_tx_seq, 0);
1357
1358        let mut batch = db.batch();
1359        batch
1360            .merge(&schema.transaction_bitmap, &tx_low_key, &tx_low_value)
1361            .unwrap();
1362        batch
1363            .merge(&schema.transaction_bitmap, &tx_high_key, &tx_high_value)
1364            .unwrap();
1365        batch
1366            .merge(&schema.event_bitmap, &event_low_key, &event_low_value)
1367            .unwrap();
1368        batch
1369            .merge(&schema.event_bitmap, &event_high_key, &event_high_value)
1370            .unwrap();
1371        batch.commit().unwrap();
1372        db.flush().unwrap();
1373
1374        let (watermark_key, watermark_value) = pruning_watermark::store(&Watermarks {
1375            tx_seq_lo: floor,
1376            checkpoint_lo: 1,
1377        });
1378        let mut batch = db.batch();
1379        batch
1380            .put(&schema.pruning_watermark, &watermark_key, &watermark_value)
1381            .unwrap();
1382        batch.commit().unwrap();
1383        schema.set_pruning_floor(floor);
1384
1385        db.compact_range_cf(transaction_bitmap::NAME, None, None)
1386            .unwrap();
1387        db.compact_range_cf(event_bitmap::NAME, None, None).unwrap();
1388
1389        assert!(
1390            schema
1391                .get_transaction_bitmap(dimension.clone(), tx_low_key.bucket)
1392                .unwrap()
1393                .is_some()
1394        );
1395        assert!(
1396            schema
1397                .get_transaction_bitmap(dimension.clone(), tx_high_key.bucket)
1398                .unwrap()
1399                .is_some()
1400        );
1401        assert!(
1402            schema
1403                .get_event_bitmap(dimension.clone(), event_low_key.bucket)
1404                .unwrap()
1405                .is_some()
1406        );
1407        assert!(
1408            schema
1409                .get_event_bitmap(dimension.clone(), event_high_key.bucket)
1410                .unwrap()
1411                .is_some()
1412        );
1413
1414        db.compact_range_cf(transaction_bitmap::NAME, None, None)
1415            .unwrap();
1416        db.compact_range_cf(event_bitmap::NAME, None, None).unwrap();
1417
1418        assert!(
1419            schema
1420                .get_transaction_bitmap(dimension.clone(), tx_low_key.bucket)
1421                .unwrap()
1422                .is_none()
1423        );
1424        assert!(
1425            schema
1426                .get_transaction_bitmap(dimension.clone(), tx_high_key.bucket)
1427                .unwrap()
1428                .is_some()
1429        );
1430        assert!(
1431            schema
1432                .get_event_bitmap(dimension.clone(), event_low_key.bucket)
1433                .unwrap()
1434                .is_none()
1435        );
1436        assert!(
1437            schema
1438                .get_event_bitmap(dimension, event_high_key.bucket)
1439                .unwrap()
1440                .is_some()
1441        );
1442    }
1443
1444    /// A committed chunk publishes its `tx_seq_lo` to this database's
1445    /// bitmap compaction filters.
1446    #[tokio::test]
1447    async fn prune_chunk_publishes_the_db_local_bitmap_floor() {
1448        let (_dir, db, schema) = fresh_db();
1449        let checkpoint = Arc::new(
1450            TestCheckpointBuilder::new(0)
1451                .start_transaction(0)
1452                .create_owned_object(0)
1453                .finish_transaction()
1454                .start_transaction(0)
1455                .transfer_object(0, 1)
1456                .finish_transaction()
1457                .build_checkpoint(),
1458        );
1459        seed(&db, &schema, &checkpoint).await;
1460
1461        let metrics = PrunerMetrics::new(None, &Registry::new());
1462        let new = prune_chunk(
1463            &db,
1464            &schema,
1465            &mut RetractionCursors::default(),
1466            Watermarks::default(),
1467            1,
1468            &metrics,
1469        )
1470        .unwrap();
1471        assert_eq!(
1472            schema.current_pruning_floor(),
1473            new.tx_seq_lo,
1474            "the chunk must publish its committed tx_seq floor",
1475        );
1476    }
1477
1478    #[test]
1479    fn start_pruner_rejects_zero_retention() {
1480        let (_dir, db, schema) = fresh_db();
1481        let store = Store::new(db, Arc::new(schema));
1482        let config = PrunerConfig {
1483            retention_epochs: 0,
1484            ..PrunerConfig::default()
1485        };
1486        let err =
1487            start_pruner(store, config, PrunerMetrics::new(None, &Registry::new())).unwrap_err();
1488        assert!(
1489            format!("{err:#}").contains("retention_epochs"),
1490            "expected a retention_epochs validation error, got: {err:#}",
1491        );
1492    }
1493
1494    #[test]
1495    fn start_pruner_rejects_zero_checkpoints_per_tick() {
1496        let (_dir, db, schema) = fresh_db();
1497        let store = Store::new(db, Arc::new(schema));
1498        let config = PrunerConfig {
1499            max_checkpoints_per_tick: 0,
1500            ..PrunerConfig::default()
1501        };
1502        let err =
1503            start_pruner(store, config, PrunerMetrics::new(None, &Registry::new())).unwrap_err();
1504        assert!(
1505            format!("{err:#}").contains("max_checkpoints_per_tick"),
1506            "expected a max_checkpoints_per_tick validation error, got: {err:#}",
1507        );
1508    }
1509
1510    /// A single `prune_once` pass advances the floor by at most
1511    /// `max_checkpoints_per_tick` checkpoints, and successive passes
1512    /// converge to the retention target. Five single-transaction
1513    /// checkpoints are eligible (retention floor at checkpoint 5); a
1514    /// per-tick budget of 2 must take three passes to drain them
1515    /// (2, 4, 5), after which the floor sits at the target and further
1516    /// passes are no-ops.
1517    #[tokio::test]
1518    async fn prune_once_advances_at_most_the_per_tick_budget() {
1519        let (_dir, db, schema) = fresh_db();
1520
1521        // Five single-transaction checkpoints (seq 0..=4) from one
1522        // accumulating builder, so `network_total_transactions` grows
1523        // by one per checkpoint and the pruned tx range is contiguous.
1524        let mut builder = TestCheckpointBuilder::new(0);
1525        let mut checkpoints = Vec::new();
1526        for i in 0..5u64 {
1527            builder = builder
1528                .start_transaction(0)
1529                .create_owned_object(i)
1530                .finish_transaction();
1531            checkpoints.push(Arc::new(builder.build_checkpoint()));
1532        }
1533        for cp in &checkpoints {
1534            seed(&db, &schema, cp).await;
1535        }
1536
1537        // Drive the target floor: the committed epoch is 2, and with
1538        // `retention_epochs = 1` the oldest retained epoch is 2, whose
1539        // start checkpoint (5) is the target floor — so checkpoints
1540        // [0, 5) are eligible.
1541        let framework = FrameworkSchema::new(db.clone());
1542        let mut batch = db.batch();
1543        batch
1544            .put(
1545                &framework.watermarks,
1546                &PipelineTaskKey::new("p"),
1547                &Watermark {
1548                    epoch_hi_inclusive: 2,
1549                    ..Default::default()
1550                },
1551            )
1552            .unwrap();
1553        batch
1554            .merge(
1555                &schema.epochs,
1556                &U64Be(2),
1557                &epochs::start(1, 1, 0, Some(5), None),
1558            )
1559            .unwrap();
1560        batch.commit().unwrap();
1561
1562        let config = PrunerConfig {
1563            retention_epochs: 1,
1564            interval_ms: 1,
1565            max_chunk_checkpoints: 2,
1566            max_checkpoints_per_tick: 2,
1567        };
1568        let metrics = PrunerMetrics::new(None, &Registry::new());
1569
1570        let floor = |schema: &RpcStoreSchema| {
1571            schema
1572                .get_pruning_watermarks()
1573                .unwrap()
1574                .unwrap_or_default()
1575                .checkpoint_lo
1576        };
1577
1578        // Each pass advances by at most the per-tick budget of 2.
1579        let mut cursors = RetractionCursors::default();
1580        prune_once(&db, &schema, &mut cursors, &config, &metrics).unwrap();
1581        assert_eq!(floor(&schema), 2, "first tick advances by the budget");
1582        prune_once(&db, &schema, &mut cursors, &config, &metrics).unwrap();
1583        assert_eq!(floor(&schema), 4, "second tick advances by the budget");
1584        prune_once(&db, &schema, &mut cursors, &config, &metrics).unwrap();
1585        assert_eq!(floor(&schema), 5, "third tick reaches the target");
1586        // Caught up: history below the floor is gone, the live target
1587        // boundary is retained, and another pass is a no-op.
1588        assert!(schema.get_effects(4).unwrap().is_none());
1589        assert!(schema.get_checkpoint_summary(4).unwrap().is_none());
1590        prune_once(&db, &schema, &mut cursors, &config, &metrics).unwrap();
1591        assert_eq!(floor(&schema), 5, "a pass at the target is a no-op");
1592    }
1593
1594    /// End-to-end chunk prune: one checkpoint where tx0 creates an
1595    /// object and tx1 transfers it (superseding the first version).
1596    /// Pruning the chunk must range-delete the per-tx / per-checkpoint
1597    /// CFs, point-delete the digest reverse indexes, retract the
1598    /// superseded object version, preserve the live version, and
1599    /// advance the persisted floor.
1600    #[tokio::test]
1601    async fn prune_chunk_deletes_history_and_preserves_live_object() {
1602        let (_dir, db, schema) = fresh_db();
1603
1604        let checkpoint = Arc::new(
1605            TestCheckpointBuilder::new(0)
1606                .start_transaction(0)
1607                .create_owned_object(0)
1608                .finish_transaction()
1609                .start_transaction(0)
1610                .transfer_object(0, 1)
1611                .finish_transaction()
1612                .build_checkpoint(),
1613        );
1614
1615        let obj0 = TestCheckpointBuilder::derive_object_id(0);
1616        let v_a = checkpoint.transactions[0].effects.lamport_version();
1617        let v_b = checkpoint.transactions[1].effects.lamport_version();
1618        assert_ne!(v_a, v_b, "the transfer must bump the object's version");
1619        let digest0 = *checkpoint.transactions[0].effects.transaction_digest();
1620        let digest1 = *checkpoint.transactions[1].effects.transaction_digest();
1621        let ckpt_digest = checkpoint.summary.data().digest();
1622
1623        seed(&db, &schema, &checkpoint).await;
1624
1625        // Preconditions: both versions present, history present.
1626        assert!(schema.get_object_by_key(obj0, v_a).unwrap().is_some());
1627        assert!(schema.get_object_by_key(obj0, v_b).unwrap().is_some());
1628        assert!(schema.get_effects(0).unwrap().is_some());
1629        assert!(schema.get_effects(1).unwrap().is_some());
1630        assert!(schema.get_checkpoint_summary(0).unwrap().is_some());
1631
1632        // Prune the whole checkpoint: checkpoints [0, 1), tx [0, 2).
1633        let metrics = PrunerMetrics::new(None, &Registry::new());
1634        let new = prune_chunk(
1635            &db,
1636            &schema,
1637            &mut RetractionCursors::default(),
1638            Watermarks::default(),
1639            1,
1640            &metrics,
1641        )
1642        .unwrap();
1643        assert_eq!(
1644            new,
1645            Watermarks {
1646                tx_seq_lo: 2,
1647                checkpoint_lo: 1,
1648            },
1649        );
1650
1651        // Superseded version retracted; live version preserved.
1652        assert!(
1653            schema.get_object_by_key(obj0, v_a).unwrap().is_none(),
1654            "superseded version v_a should be pruned",
1655        );
1656        assert!(
1657            schema.get_object_by_key(obj0, v_b).unwrap().is_some(),
1658            "live version v_b must be preserved",
1659        );
1660
1661        // Range-deleted CFs are emptied over the pruned range.
1662        assert!(schema.get_effects(0).unwrap().is_none());
1663        assert!(schema.get_effects(1).unwrap().is_none());
1664        assert!(schema.get_checkpoint_summary(0).unwrap().is_none());
1665
1666        // Point-deleted digest reverse indexes are gone.
1667        assert!(
1668            schema
1669                .tx_seq_by_digest
1670                .get(&tx_seq_by_digest::Key(digest0))
1671                .unwrap()
1672                .is_none()
1673        );
1674        assert!(
1675            schema
1676                .tx_seq_by_digest
1677                .get(&tx_seq_by_digest::Key(digest1))
1678                .unwrap()
1679                .is_none()
1680        );
1681        assert!(
1682            schema
1683                .checkpoint_seq_by_digest
1684                .get(&checkpoint_seq_by_digest::Key(ckpt_digest))
1685                .unwrap()
1686                .is_none()
1687        );
1688
1689        // The persisted floor advanced.
1690        assert_eq!(
1691            schema.get_pruning_watermarks().unwrap().unwrap(),
1692            Watermarks {
1693                tx_seq_lo: 2,
1694                checkpoint_lo: 1,
1695            },
1696        );
1697    }
1698
1699    /// Advance the floor across two single-checkpoint chunks and
1700    /// confirm a superseded object version is retracted only once the
1701    /// chunk containing its *superseding* transaction is pruned.
1702    ///
1703    /// Checkpoint 0 creates `obj0@v_a`; checkpoint 1 transfers it to
1704    /// `obj0@v_b`. Pruning checkpoint 0 alone must keep `v_a` (its
1705    /// superseding transaction is still live); pruning checkpoint 1
1706    /// then retracts `v_a` while preserving the live `v_b`.
1707    #[tokio::test]
1708    async fn prune_chunk_retracts_version_only_when_superseding_tx_is_pruned() {
1709        let (_dir, db, schema) = fresh_db();
1710
1711        // One builder across two checkpoints so `network_total_transactions`
1712        // accumulates and the shared live-object set carries obj0 forward.
1713        let mut builder = TestCheckpointBuilder::new(0)
1714            .start_transaction(0)
1715            .create_owned_object(0)
1716            .finish_transaction();
1717        let cp0 = Arc::new(builder.build_checkpoint());
1718        builder = builder
1719            .start_transaction(0)
1720            .transfer_object(0, 1)
1721            .finish_transaction();
1722        let cp1 = Arc::new(builder.build_checkpoint());
1723
1724        let obj0 = TestCheckpointBuilder::derive_object_id(0);
1725        let v_a = cp0.transactions[0].effects.lamport_version();
1726        let v_b = cp1.transactions[0].effects.lamport_version();
1727        assert_ne!(v_a, v_b);
1728
1729        seed(&db, &schema, &cp0).await;
1730        seed(&db, &schema, &cp1).await;
1731        let metrics = PrunerMetrics::new(None, &Registry::new());
1732        let mut cursors = RetractionCursors::default();
1733
1734        // Chunk 1: prune checkpoint 0 only (tx [0, 1)). obj0's
1735        // superseding transaction is in checkpoint 1, so v_a stays.
1736        let after_first = prune_chunk(
1737            &db,
1738            &schema,
1739            &mut cursors,
1740            Watermarks::default(),
1741            1,
1742            &metrics,
1743        )
1744        .unwrap();
1745        assert_eq!(
1746            after_first,
1747            Watermarks {
1748                tx_seq_lo: 1,
1749                checkpoint_lo: 1,
1750            },
1751        );
1752        assert!(schema.get_effects(0).unwrap().is_none());
1753        assert!(schema.get_effects(1).unwrap().is_some());
1754        assert!(
1755            schema.get_object_by_key(obj0, v_a).unwrap().is_some(),
1756            "v_a must survive while its superseding tx is still retained",
1757        );
1758
1759        // Chunk 2: prune checkpoint 1 (tx [1, 2)). Now the superseding
1760        // transaction is pruned, retracting v_a; v_b remains live.
1761        let after_second =
1762            prune_chunk(&db, &schema, &mut cursors, after_first, 2, &metrics).unwrap();
1763        assert_eq!(
1764            after_second,
1765            Watermarks {
1766                tx_seq_lo: 2,
1767                checkpoint_lo: 2,
1768            },
1769        );
1770        assert!(schema.get_effects(1).unwrap().is_none());
1771        assert!(
1772            schema.get_object_by_key(obj0, v_a).unwrap().is_none(),
1773            "v_a must be retracted once its superseding tx is pruned",
1774        );
1775        assert!(
1776            schema.get_object_by_key(obj0, v_b).unwrap().is_some(),
1777            "live v_b must be preserved",
1778        );
1779    }
1780
1781    /// The checkpoint-pinned `object_version_by_checkpoint` index is
1782    /// retracted in lockstep with the `objects` history: a
1783    /// checkpoint-pinned entry survives until the transaction that
1784    /// supersedes its object is pruned, and is dropped once that
1785    /// transaction's checkpoint ages out.
1786    ///
1787    /// Checkpoint 0 creates `obj0@v_a`; checkpoint 1 transfers it to
1788    /// `obj0@v_b`. Pruning checkpoint 0 keeps the cp0-pinned entry (its
1789    /// superseding transaction is still retained); pruning checkpoint 1
1790    /// retracts it while preserving the cp1-pinned floor entry.
1791    #[tokio::test]
1792    async fn prune_chunk_retracts_object_version_by_checkpoint() {
1793        let (_dir, db, schema) = fresh_db();
1794
1795        let mut builder = TestCheckpointBuilder::new(0)
1796            .start_transaction(0)
1797            .create_owned_object(0)
1798            .finish_transaction();
1799        let cp0 = Arc::new(builder.build_checkpoint());
1800        builder = builder
1801            .start_transaction(0)
1802            .transfer_object(0, 1)
1803            .finish_transaction();
1804        let cp1 = Arc::new(builder.build_checkpoint());
1805
1806        let obj0 = TestCheckpointBuilder::derive_object_id(0);
1807        let v_a = cp0.transactions[0].effects.lamport_version();
1808        let v_b = cp1.transactions[0].effects.lamport_version();
1809        assert_ne!(v_a, v_b);
1810
1811        // Seed the base CFs the pruner reads (`seed` populates
1812        // `checkpoint_summary`, from which the pruner derives each
1813        // transaction's checkpoint) plus the checkpoint-pinned index
1814        // under test.
1815        // Seed only the change rows; the floor candidates are exercised in
1816        // the pipeline's own tests.
1817        for cp in [&cp0, &cp1] {
1818            seed(&db, &schema, cp).await;
1819            seed_object_version_by_checkpoint(&db, &schema, cp).await;
1820        }
1821
1822        // Precondition: obj0 resolves at both checkpoints.
1823        assert_eq!(
1824            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
1825            Some(v_a),
1826        );
1827        assert_eq!(
1828            schema.get_object_version_at_checkpoint(obj0, 1).unwrap(),
1829            Some(v_b),
1830        );
1831
1832        let metrics = PrunerMetrics::new(None, &Registry::new());
1833        let mut cursors = RetractionCursors::default();
1834
1835        // Prune checkpoint 0 only: tx0 creates obj0 and supersedes
1836        // nothing, so the cp0-pinned entry survives.
1837        let after_first = prune_chunk(
1838            &db,
1839            &schema,
1840            &mut cursors,
1841            Watermarks::default(),
1842            1,
1843            &metrics,
1844        )
1845        .unwrap();
1846        assert_eq!(
1847            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
1848            Some(v_a),
1849            "cp0-pinned entry must survive while its superseding tx is retained",
1850        );
1851
1852        // Prune checkpoint 1: tx1 supersedes obj0@v_a, retracting the
1853        // cp0-pinned entry; the cp1-pinned floor entry remains.
1854        prune_chunk(&db, &schema, &mut cursors, after_first, 2, &metrics).unwrap();
1855        assert_eq!(
1856            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
1857            None,
1858            "cp0-pinned entry must be retracted once its superseding tx is pruned",
1859        );
1860        assert_eq!(
1861            schema.get_object_version_at_checkpoint(obj0, 1).unwrap(),
1862            Some(v_b),
1863            "cp1-pinned floor entry must be preserved",
1864        );
1865    }
1866
1867    /// An object removed and then re-created by separate transactions of one
1868    /// checkpoint is live at its end, so pruning that checkpoint keeps the row
1869    /// a read at the floor resolves to.
1870    #[tokio::test]
1871    async fn prune_chunk_keeps_object_removed_and_recreated_in_one_checkpoint() {
1872        let (_dir, db, schema) = fresh_db();
1873        let (cp0, cp1) = removed_and_recreated_checkpoints();
1874        let obj0 = TestCheckpointBuilder::derive_object_id(0);
1875        let obj1 = TestCheckpointBuilder::derive_object_id(1);
1876        let v0 = cp1.transactions[1].effects.lamport_version();
1877        let v1 = cp1.transactions[3].effects.lamport_version();
1878
1879        for cp in [&cp0, &cp1] {
1880            seed(&db, &schema, cp).await;
1881            seed_object_version_by_checkpoint(&db, &schema, cp).await;
1882        }
1883        assert_eq!(
1884            schema.get_object_version_at_checkpoint(obj0, 1).unwrap(),
1885            Some(v0),
1886        );
1887        assert_eq!(
1888            schema.get_object_version_at_checkpoint(obj1, 1).unwrap(),
1889            Some(v1),
1890        );
1891
1892        let metrics = PrunerMetrics::new(None, &Registry::new());
1893        prune_chunk(
1894            &db,
1895            &schema,
1896            &mut RetractionCursors::default(),
1897            Watermarks::default(),
1898            2,
1899            &metrics,
1900        )
1901        .unwrap();
1902
1903        assert_eq!(
1904            schema.get_object_version_at_checkpoint(obj0, 1).unwrap(),
1905            Some(v0),
1906            "a deleted then re-created object must keep its live row",
1907        );
1908        assert_eq!(
1909            schema.get_object_version_at_checkpoint(obj1, 1).unwrap(),
1910            Some(v1),
1911            "a wrapped then unwrapped object must keep its live row",
1912        );
1913        assert!(schema.get_object_by_key(obj0, v0).unwrap().is_some());
1914        assert!(schema.get_object_by_key(obj1, v1).unwrap().is_some());
1915    }
1916
1917    /// `prune_history_cohort` keeps the live row of an object removed and then
1918    /// re-created by separate transactions of one pruned checkpoint.
1919    #[tokio::test]
1920    async fn prune_history_cohort_keeps_object_removed_and_recreated_in_one_checkpoint() {
1921        let (_dir, db, schema) = fresh_db();
1922        let (cp0, cp1) = removed_and_recreated_checkpoints();
1923        let obj0 = TestCheckpointBuilder::derive_object_id(0);
1924        let obj1 = TestCheckpointBuilder::derive_object_id(1);
1925        let v0 = cp1.transactions[1].effects.lamport_version();
1926        let v1 = cp1.transactions[3].effects.lamport_version();
1927
1928        for cp in [&cp0, &cp1] {
1929            seed_object_version_by_checkpoint(&db, &schema, cp).await;
1930        }
1931
1932        let effects: Vec<(u64, TransactionEffects)> = cp0
1933            .transactions
1934            .iter()
1935            .map(|tx| (0u64, tx.effects.clone()))
1936            .chain(cp1.transactions.iter().map(|tx| (1u64, tx.effects.clone())))
1937            .collect();
1938        prune_history_cohort(
1939            &db,
1940            &schema,
1941            &mut RetractionCursors::default(),
1942            1,
1943            0,
1944            &effects,
1945        )
1946        .unwrap();
1947
1948        assert_eq!(
1949            schema.get_object_version_at_checkpoint(obj0, 2).unwrap(),
1950            Some(v0),
1951            "a deleted then re-created object must keep its live row",
1952        );
1953        assert_eq!(
1954            schema.get_object_version_at_checkpoint(obj1, 2).unwrap(),
1955            Some(v1),
1956            "a wrapped then unwrapped object must keep its live row",
1957        );
1958    }
1959
1960    /// The embedded entry point retracts `object_version_by_checkpoint`
1961    /// effects-driven, matching the standalone `prune_chunk`: a superseded
1962    /// object keeps only its latest sub-floor row (the anchor), while a
1963    /// removed object's rows are dropped entirely.
1964    #[test]
1965    fn prune_history_cohort_retracts_object_version_by_checkpoint() {
1966        let (_dir, db, schema) = fresh_db();
1967
1968        // Real checkpoints, built only for their effects: cp0 creates obj0
1969        // and obj1; cp1 transfers obj0 (supersedes it) and deletes obj1.
1970        let mut builder = TestCheckpointBuilder::new(0)
1971            .start_transaction(0)
1972            .create_owned_object(0)
1973            .create_owned_object(1)
1974            .finish_transaction();
1975        let cp0 = Arc::new(builder.build_checkpoint());
1976        builder = builder
1977            .start_transaction(0)
1978            .transfer_object(0, 1)
1979            .finish_transaction()
1980            .start_transaction(0)
1981            .delete_object(1)
1982            .finish_transaction();
1983        let cp1 = Arc::new(builder.build_checkpoint());
1984
1985        let obj0 = TestCheckpointBuilder::derive_object_id(0);
1986        let obj1 = TestCheckpointBuilder::derive_object_id(1);
1987
1988        // Seed the checkpoint-pinned rows directly (values are immaterial to
1989        // the retraction): obj0 changed in cp0 and cp1; obj1 was created in
1990        // cp0 and tombstoned in cp1.
1991        let ver = |n: u64| sui_types::base_types::SequenceNumber::from_u64(n);
1992        let mut batch = db.batch();
1993        for (id, checkpoint, version) in [(obj0, 0, 1), (obj0, 1, 2), (obj1, 0, 1), (obj1, 1, 2)] {
1994            let (k, v) = object_version_by_checkpoint::store(id, checkpoint, ver(version));
1995            batch
1996                .put(&schema.object_version_by_checkpoint, &k, &v)
1997                .unwrap();
1998        }
1999        batch.commit().unwrap();
2000
2001        // Precondition: both objects resolve at their creation checkpoint.
2002        assert_eq!(
2003            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
2004            Some(ver(1)),
2005        );
2006        assert_eq!(
2007            schema.get_object_version_at_checkpoint(obj1, 0).unwrap(),
2008            Some(ver(1)),
2009        );
2010
2011        // Prune through checkpoint 1 (new floor 2), feeding the pruned
2012        // checkpoints' effects tagged with the checkpoint each came from.
2013        // `pruned_tx_seq_exclusive` is 0 here: the tx-keyed CFs are empty in
2014        // this test, and the checkpoint floor advancing alone is enough.
2015        let effects: Vec<(u64, TransactionEffects)> = cp0
2016            .transactions
2017            .iter()
2018            .map(|tx| (0u64, tx.effects.clone()))
2019            .chain(cp1.transactions.iter().map(|tx| (1u64, tx.effects.clone())))
2020            .collect();
2021        prune_history_cohort(
2022            &db,
2023            &schema,
2024            &mut RetractionCursors::default(),
2025            1,
2026            0,
2027            &effects,
2028        )
2029        .unwrap();
2030        // obj0 was superseded in cp1: its cp0 row is retracted, the cp1
2031        // anchor survives to resolve reads at or above the floor.
2032        assert_eq!(
2033            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
2034            None,
2035            "a superseded object's pre-anchor row must be retracted",
2036        );
2037        assert_eq!(
2038            schema.get_object_version_at_checkpoint(obj0, 2).unwrap(),
2039            Some(ver(2)),
2040            "the anchor a read at the floor resolves to must survive",
2041        );
2042
2043        // obj1 was removed in cp1: all its sub-floor rows are dropped.
2044        assert_eq!(
2045            schema.get_object_version_at_checkpoint(obj1, 2).unwrap(),
2046            None,
2047            "a removed object's rows must be dropped entirely",
2048        );
2049
2050        // The floor advanced to the new lowest-available checkpoint.
2051        assert_eq!(
2052            schema
2053                .get_pruning_watermarks()
2054                .unwrap()
2055                .unwrap()
2056                .checkpoint_lo,
2057            2,
2058        );
2059    }
2060
2061    /// `prune_history_cohort` (the embedded entry point) range-deletes
2062    /// `tx_metadata_by_seq`, point-deletes `tx_seq_by_digest` for the
2063    /// pruned digests, and advances the persisted floor — all from the
2064    /// floor the perpetual pruner supplies, without touching any raw
2065    /// chain-data CF.
2066    #[test]
2067    fn prune_history_cohort_deletes_tx_metadata_and_advances_floor() {
2068        use sui_types::digests::TransactionDigest;
2069
2070        use crate::schema::tx_metadata_by_seq;
2071
2072        let (_dir, db, schema) = fresh_db();
2073
2074        // Six transactions, tx_seq 0..6, each with a metadata row and a
2075        // digest -> tx_seq reverse-index entry.
2076        let digests: Vec<TransactionDigest> =
2077            (0u8..6).map(|i| TransactionDigest::new([i; 32])).collect();
2078        let mut batch = db.batch();
2079        for (tx_seq, digest) in digests.iter().enumerate() {
2080            let tx_seq = tx_seq as u64;
2081            batch
2082                .put(
2083                    &schema.tx_metadata_by_seq,
2084                    &U64Be(tx_seq),
2085                    &tx_metadata_by_seq::store(&tx_metadata_by_seq::Metadata {
2086                        digest: *digest,
2087                        checkpoint_seq: tx_seq,
2088                        ckpt_position: 0,
2089                        event_count: 0,
2090                        timestamp_ms: 0,
2091                    }),
2092                )
2093                .unwrap();
2094            batch
2095                .put(
2096                    &schema.tx_seq_by_digest,
2097                    &tx_seq_by_digest::Key(*digest),
2098                    &U64Varint(tx_seq),
2099                )
2100                .unwrap();
2101        }
2102        batch.commit().unwrap();
2103
2104        // Perpetual store has pruned through checkpoint 2; tx_seq 3 is
2105        // the first still-retained transaction.
2106        prune_history_cohort(&db, &schema, &mut RetractionCursors::default(), 2, 3, &[]).unwrap();
2107        // tx_metadata 0..3 pruned, 3..6 retained.
2108        for tx_seq in 0..3 {
2109            assert!(
2110                schema.get_tx_metadata_by_seq(tx_seq).unwrap().is_none(),
2111                "tx_metadata {tx_seq} should be pruned",
2112            );
2113        }
2114        for tx_seq in 3..6 {
2115            assert!(
2116                schema.get_tx_metadata_by_seq(tx_seq).unwrap().is_some(),
2117                "tx_metadata {tx_seq} should be retained",
2118            );
2119        }
2120
2121        // Digest reverse index unindexed for the pruned range only.
2122        for digest in &digests[0..3] {
2123            assert!(schema.get_tx_seq_by_digest(digest).unwrap().is_none());
2124        }
2125        for digest in &digests[3..6] {
2126            assert!(schema.get_tx_seq_by_digest(digest).unwrap().is_some());
2127        }
2128
2129        // Floor advanced: tx_seq 3 and checkpoint 3 (= pruned 2 + 1).
2130        assert_eq!(
2131            schema.get_pruning_watermarks().unwrap(),
2132            Some(Watermarks {
2133                tx_seq_lo: 3,
2134                checkpoint_lo: 3,
2135            }),
2136        );
2137
2138        // Idempotent: a re-run at the same floor is a no-op.
2139        prune_history_cohort(&db, &schema, &mut RetractionCursors::default(), 2, 3, &[]).unwrap();
2140        assert_eq!(
2141            schema.get_pruning_watermarks().unwrap(),
2142            Some(Watermarks {
2143                tx_seq_lo: 3,
2144                checkpoint_lo: 3,
2145            }),
2146        );
2147    }
2148
2149    /// `prune_history_cohort` visits only the rows that exist when the
2150    /// floor is unknown (no prior watermark, so `tx_lo == 0`) and the
2151    /// `tx_seq` range is sparse with large gaps — it must not walk every
2152    /// integer in the interval.
2153    #[test]
2154    fn prune_history_cohort_handles_sparse_tx_seqs() {
2155        use sui_types::digests::TransactionDigest;
2156
2157        use crate::schema::tx_metadata_by_seq;
2158
2159        let (_dir, db, schema) = fresh_db();
2160
2161        // Three rows spread across a wide interval.
2162        let entries = [
2163            (0u64, [10u8; 32]),
2164            (500_000u64, [11u8; 32]),
2165            (999_999u64, [12u8; 32]),
2166        ];
2167        let mut batch = db.batch();
2168        for (tx_seq, digest_bytes) in entries {
2169            let digest = TransactionDigest::new(digest_bytes);
2170            batch
2171                .put(
2172                    &schema.tx_metadata_by_seq,
2173                    &U64Be(tx_seq),
2174                    &tx_metadata_by_seq::store(&tx_metadata_by_seq::Metadata {
2175                        digest,
2176                        checkpoint_seq: tx_seq,
2177                        ckpt_position: 0,
2178                        event_count: 0,
2179                        timestamp_ms: 0,
2180                    }),
2181                )
2182                .unwrap();
2183            batch
2184                .put(
2185                    &schema.tx_seq_by_digest,
2186                    &tx_seq_by_digest::Key(digest),
2187                    &U64Varint(tx_seq),
2188                )
2189                .unwrap();
2190        }
2191        batch.commit().unwrap();
2192
2193        // No prior pruning watermark (floor unknown -> 0); prune through
2194        // checkpoint 0 / tx_seq 600_000 exclusive. Only the two rows
2195        // below 600_000 are unindexed; the one at 999_999 survives.
2196        prune_history_cohort(
2197            &db,
2198            &schema,
2199            &mut RetractionCursors::default(),
2200            0,
2201            600_000,
2202            &[],
2203        )
2204        .unwrap();
2205        assert!(schema.get_tx_metadata_by_seq(0).unwrap().is_none());
2206        assert!(schema.get_tx_metadata_by_seq(500_000).unwrap().is_none());
2207        assert!(schema.get_tx_metadata_by_seq(999_999).unwrap().is_some());
2208        assert!(
2209            schema
2210                .get_tx_seq_by_digest(&TransactionDigest::new([10u8; 32]))
2211                .unwrap()
2212                .is_none()
2213        );
2214        assert!(
2215            schema
2216                .get_tx_seq_by_digest(&TransactionDigest::new([11u8; 32]))
2217                .unwrap()
2218                .is_none()
2219        );
2220        assert!(
2221            schema
2222                .get_tx_seq_by_digest(&TransactionDigest::new([12u8; 32]))
2223                .unwrap()
2224                .is_some()
2225        );
2226        assert_eq!(
2227            schema.get_pruning_watermarks().unwrap(),
2228            Some(Watermarks {
2229                tx_seq_lo: 600_000,
2230                checkpoint_lo: 1,
2231            }),
2232        );
2233    }
2234    fn all_object_version_by_checkpoint_rows(
2235        schema: &RpcStoreSchema,
2236    ) -> Vec<(
2237        object_version_by_checkpoint::Key,
2238        sui_types::base_types::SequenceNumber,
2239    )> {
2240        schema
2241            .object_version_by_checkpoint
2242            .iter(..)
2243            .unwrap()
2244            .map(|res| {
2245                let (key, value) = res.unwrap();
2246                (
2247                    key,
2248                    sui_types::base_types::SequenceNumber::from_u64(value.into_inner().version),
2249                )
2250            })
2251            .collect()
2252    }
2253    /// Differential test: an identical multi-batch prune sequence executed
2254    /// with persistent cursors vs. fresh/unwarmed cursors produces byte-identical
2255    /// `object_version_by_checkpoint` CF contents.
2256    #[test]
2257    fn prune_history_cohort_differential_with_warm_and_fresh_cursors() {
2258        let (_dir_warm, db_warm, schema_warm) = fresh_db();
2259        let (_dir_fresh, db_fresh, schema_fresh) = fresh_db();
2260
2261        let mut builder = TestCheckpointBuilder::new(0)
2262            .start_transaction(0)
2263            .create_owned_object(0)
2264            .create_owned_object(1)
2265            .create_owned_object(2)
2266            .finish_transaction();
2267        let cp0 = Arc::new(builder.build_checkpoint());
2268
2269        builder = builder
2270            .start_transaction(0)
2271            .transfer_object(0, 1)
2272            .transfer_object(1, 2)
2273            .create_owned_object(3)
2274            .finish_transaction();
2275        let cp1 = Arc::new(builder.build_checkpoint());
2276
2277        builder = builder
2278            .start_transaction(0)
2279            .transfer_object(0, 2)
2280            .delete_object(2)
2281            .create_owned_object(4)
2282            .finish_transaction();
2283        let cp2 = Arc::new(builder.build_checkpoint());
2284
2285        builder = builder
2286            .start_transaction(0)
2287            .transfer_object(0, 3)
2288            .transfer_object(3, 4)
2289            .delete_object(4)
2290            .finish_transaction();
2291        let cp3 = Arc::new(builder.build_checkpoint());
2292
2293        builder = builder
2294            .start_transaction(0)
2295            .transfer_object(0, 4)
2296            .delete_object(1)
2297            .transfer_object(3, 5)
2298            .finish_transaction();
2299        let cp4 = Arc::new(builder.build_checkpoint());
2300
2301        builder = builder
2302            .start_transaction(0)
2303            .transfer_object(0, 5)
2304            .finish_transaction();
2305        let cp5 = Arc::new(builder.build_checkpoint());
2306
2307        let obj0 = TestCheckpointBuilder::derive_object_id(0);
2308        let obj1 = TestCheckpointBuilder::derive_object_id(1);
2309        let obj2 = TestCheckpointBuilder::derive_object_id(2);
2310        let obj3 = TestCheckpointBuilder::derive_object_id(3);
2311        let obj4 = TestCheckpointBuilder::derive_object_id(4);
2312
2313        let ver = |n: u64| sui_types::base_types::SequenceNumber::from_u64(n);
2314        let rows = [
2315            (obj0, 0, 1),
2316            (obj0, 1, 2),
2317            (obj0, 2, 3),
2318            (obj0, 3, 4),
2319            (obj0, 4, 5),
2320            (obj0, 5, 6),
2321            (obj1, 0, 1),
2322            (obj1, 1, 2),
2323            (obj1, 4, 3),
2324            (obj2, 0, 1),
2325            (obj2, 2, 2),
2326            (obj3, 1, 1),
2327            (obj3, 3, 2),
2328            (obj3, 4, 3),
2329            (obj4, 2, 1),
2330            (obj4, 3, 2),
2331        ];
2332
2333        for (db, schema) in [(&db_warm, &schema_warm), (&db_fresh, &schema_fresh)] {
2334            let mut batch = db.batch();
2335            for (id, cp, version) in rows {
2336                let (k, v) = object_version_by_checkpoint::store(id, cp, ver(version));
2337                batch
2338                    .put(&schema.object_version_by_checkpoint, &k, &v)
2339                    .unwrap();
2340            }
2341            batch.commit().unwrap();
2342        }
2343
2344        let checkpoints = [
2345            (&cp0, 0u64),
2346            (&cp1, 1),
2347            (&cp2, 2),
2348            (&cp3, 3),
2349            (&cp4, 4),
2350            (&cp5, 5),
2351        ];
2352
2353        // Batch 1: prune through cp 1
2354        let effects_b1: Vec<(u64, TransactionEffects)> = checkpoints[0..=1]
2355            .iter()
2356            .flat_map(|(cp, seq)| {
2357                cp.transactions
2358                    .iter()
2359                    .map(move |tx| (*seq, tx.effects.clone()))
2360            })
2361            .collect();
2362        // Batch 2: prune through cp 3
2363        let effects_b2: Vec<(u64, TransactionEffects)> = checkpoints[2..=3]
2364            .iter()
2365            .flat_map(|(cp, seq)| {
2366                cp.transactions
2367                    .iter()
2368                    .map(move |tx| (*seq, tx.effects.clone()))
2369            })
2370            .collect();
2371        // Batch 3: prune through cp 5
2372        let effects_b3: Vec<(u64, TransactionEffects)> = checkpoints[4..=5]
2373            .iter()
2374            .flat_map(|(cp, seq)| {
2375                cp.transactions
2376                    .iter()
2377                    .map(move |tx| (*seq, tx.effects.clone()))
2378            })
2379            .collect();
2380
2381        // Run with persistent warm cursors
2382        let mut warm_cursors = RetractionCursors::default();
2383        prune_history_cohort(&db_warm, &schema_warm, &mut warm_cursors, 1, 0, &effects_b1).unwrap();
2384        prune_history_cohort(&db_warm, &schema_warm, &mut warm_cursors, 3, 0, &effects_b2).unwrap();
2385        prune_history_cohort(&db_warm, &schema_warm, &mut warm_cursors, 5, 0, &effects_b3).unwrap();
2386
2387        // Run with fresh cursors every batch (simulating full cache misses / lower bound 0 fallback)
2388        prune_history_cohort(
2389            &db_fresh,
2390            &schema_fresh,
2391            &mut RetractionCursors::default(),
2392            1,
2393            0,
2394            &effects_b1,
2395        )
2396        .unwrap();
2397        prune_history_cohort(
2398            &db_fresh,
2399            &schema_fresh,
2400            &mut RetractionCursors::default(),
2401            3,
2402            0,
2403            &effects_b2,
2404        )
2405        .unwrap();
2406        prune_history_cohort(
2407            &db_fresh,
2408            &schema_fresh,
2409            &mut RetractionCursors::default(),
2410            5,
2411            0,
2412            &effects_b3,
2413        )
2414        .unwrap();
2415
2416        let rows_warm = all_object_version_by_checkpoint_rows(&schema_warm);
2417        let rows_fresh = all_object_version_by_checkpoint_rows(&schema_fresh);
2418        assert_eq!(rows_warm, rows_fresh);
2419        // Non-vacuity anchor: the retractions really deleted superseded
2420        // rows — obj0 (transferred in every checkpoint) keeps only its
2421        // cp5 anchor.
2422        let obj0_checkpoints: Vec<u64> = rows_warm
2423            .iter()
2424            .filter(|(k, _)| k.id == obj0)
2425            .map(|(k, _)| k.checkpoint)
2426            .collect();
2427        assert_eq!(obj0_checkpoints, vec![5], "obj0 keeps only its cp5 anchor");
2428    }
2429
2430    /// Simulated restart test: dropping the cursor state mid-sequence produces
2431    /// CF contents identical to an uninterrupted run.
2432    #[test]
2433    fn prune_history_cohort_simulated_restart() {
2434        let (_dir_uninterrupted, db_uninterrupted, schema_uninterrupted) = fresh_db();
2435        let (_dir_restarted, db_restarted, schema_restarted) = fresh_db();
2436
2437        let mut builder = TestCheckpointBuilder::new(0)
2438            .start_transaction(0)
2439            .create_owned_object(0)
2440            .create_owned_object(1)
2441            .finish_transaction();
2442        let cp0 = Arc::new(builder.build_checkpoint());
2443
2444        builder = builder
2445            .start_transaction(0)
2446            .transfer_object(0, 1)
2447            .transfer_object(1, 2)
2448            .finish_transaction();
2449        let cp1 = Arc::new(builder.build_checkpoint());
2450
2451        builder = builder
2452            .start_transaction(0)
2453            .transfer_object(0, 2)
2454            .delete_object(1)
2455            .finish_transaction();
2456        let cp2 = Arc::new(builder.build_checkpoint());
2457
2458        builder = builder
2459            .start_transaction(0)
2460            .transfer_object(0, 3)
2461            .finish_transaction();
2462        let cp3 = Arc::new(builder.build_checkpoint());
2463
2464        let obj0 = TestCheckpointBuilder::derive_object_id(0);
2465        let obj1 = TestCheckpointBuilder::derive_object_id(1);
2466
2467        let ver = |n: u64| sui_types::base_types::SequenceNumber::from_u64(n);
2468        let rows = [
2469            (obj0, 0, 1),
2470            (obj0, 1, 2),
2471            (obj0, 2, 3),
2472            (obj0, 3, 4),
2473            (obj1, 0, 1),
2474            (obj1, 1, 2),
2475            (obj1, 2, 3),
2476        ];
2477
2478        for (db, schema) in [
2479            (&db_uninterrupted, &schema_uninterrupted),
2480            (&db_restarted, &schema_restarted),
2481        ] {
2482            let mut batch = db.batch();
2483            for (id, cp, version) in rows {
2484                let (k, v) = object_version_by_checkpoint::store(id, cp, ver(version));
2485                batch
2486                    .put(&schema.object_version_by_checkpoint, &k, &v)
2487                    .unwrap();
2488            }
2489            batch.commit().unwrap();
2490        }
2491
2492        let checkpoints = [(&cp0, 0u64), (&cp1, 1), (&cp2, 2), (&cp3, 3)];
2493
2494        let effects_b1: Vec<(u64, TransactionEffects)> = checkpoints[0..=1]
2495            .iter()
2496            .flat_map(|(cp, seq)| {
2497                cp.transactions
2498                    .iter()
2499                    .map(move |tx| (*seq, tx.effects.clone()))
2500            })
2501            .collect();
2502        let effects_b2: Vec<(u64, TransactionEffects)> = checkpoints[2..=3]
2503            .iter()
2504            .flat_map(|(cp, seq)| {
2505                cp.transactions
2506                    .iter()
2507                    .map(move |tx| (*seq, tx.effects.clone()))
2508            })
2509            .collect();
2510
2511        // Uninterrupted run
2512        let mut uninterrupted_cursors = RetractionCursors::default();
2513        prune_history_cohort(
2514            &db_uninterrupted,
2515            &schema_uninterrupted,
2516            &mut uninterrupted_cursors,
2517            1,
2518            0,
2519            &effects_b1,
2520        )
2521        .unwrap();
2522        prune_history_cohort(
2523            &db_uninterrupted,
2524            &schema_uninterrupted,
2525            &mut uninterrupted_cursors,
2526            3,
2527            0,
2528            &effects_b2,
2529        )
2530        .unwrap();
2531
2532        // Restarted run: cursors dropped/reset mid-sequence
2533        let mut restarted_cursors = RetractionCursors::default();
2534        prune_history_cohort(
2535            &db_restarted,
2536            &schema_restarted,
2537            &mut restarted_cursors,
2538            1,
2539            0,
2540            &effects_b1,
2541        )
2542        .unwrap();
2543        // Drop and recreate cursors (simulating restart)
2544        restarted_cursors = RetractionCursors::default();
2545        prune_history_cohort(
2546            &db_restarted,
2547            &schema_restarted,
2548            &mut restarted_cursors,
2549            3,
2550            0,
2551            &effects_b2,
2552        )
2553        .unwrap();
2554
2555        let rows_uninterrupted = all_object_version_by_checkpoint_rows(&schema_uninterrupted);
2556        let rows_restarted = all_object_version_by_checkpoint_rows(&schema_restarted);
2557        assert_eq!(rows_uninterrupted, rows_restarted);
2558    }
2559
2560    /// Hot object across batches: an object superseded in every checkpoint
2561    /// over >= 3 batches keeps exactly its latest anchor each time.
2562    #[test]
2563    fn prune_history_cohort_hot_object_across_batches() {
2564        let (_dir, db, schema) = fresh_db();
2565
2566        let mut builder = TestCheckpointBuilder::new(0)
2567            .start_transaction(0)
2568            .create_owned_object(0)
2569            .finish_transaction();
2570        let cp0 = Arc::new(builder.build_checkpoint());
2571
2572        builder = builder
2573            .start_transaction(0)
2574            .transfer_object(0, 1)
2575            .finish_transaction();
2576        let cp1 = Arc::new(builder.build_checkpoint());
2577
2578        builder = builder
2579            .start_transaction(0)
2580            .transfer_object(0, 2)
2581            .finish_transaction();
2582        let cp2 = Arc::new(builder.build_checkpoint());
2583
2584        builder = builder
2585            .start_transaction(0)
2586            .transfer_object(0, 3)
2587            .finish_transaction();
2588        let cp3 = Arc::new(builder.build_checkpoint());
2589
2590        builder = builder
2591            .start_transaction(0)
2592            .transfer_object(0, 4)
2593            .finish_transaction();
2594        let cp4 = Arc::new(builder.build_checkpoint());
2595
2596        builder = builder
2597            .start_transaction(0)
2598            .transfer_object(0, 5)
2599            .finish_transaction();
2600        let cp5 = Arc::new(builder.build_checkpoint());
2601
2602        let hot_obj = TestCheckpointBuilder::derive_object_id(0);
2603        let ver = |n: u64| sui_types::base_types::SequenceNumber::from_u64(n);
2604
2605        let mut batch = db.batch();
2606        for (checkpoint, version) in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)] {
2607            let (k, v) = object_version_by_checkpoint::store(hot_obj, checkpoint, ver(version));
2608            batch
2609                .put(&schema.object_version_by_checkpoint, &k, &v)
2610                .unwrap();
2611        }
2612        batch.commit().unwrap();
2613
2614        let mut cursors = RetractionCursors::default();
2615
2616        // Batch 1: prune through checkpoint 1 (new floor 2)
2617        let effects_b1 = vec![
2618            (0u64, cp0.transactions[0].effects.clone()),
2619            (1u64, cp1.transactions[0].effects.clone()),
2620        ];
2621        prune_history_cohort(&db, &schema, &mut cursors, 1, 0, &effects_b1).unwrap();
2622        assert_eq!(cursors.lower_bound(&hot_obj), 1);
2623        assert_eq!(
2624            schema.get_object_version_at_checkpoint(hot_obj, 0).unwrap(),
2625            None
2626        );
2627        assert_eq!(
2628            schema.get_object_version_at_checkpoint(hot_obj, 1).unwrap(),
2629            Some(ver(2))
2630        );
2631
2632        // Batch 2: prune through checkpoint 3 (new floor 4)
2633        let effects_b2 = vec![
2634            (2u64, cp2.transactions[0].effects.clone()),
2635            (3u64, cp3.transactions[0].effects.clone()),
2636        ];
2637        prune_history_cohort(&db, &schema, &mut cursors, 3, 0, &effects_b2).unwrap();
2638        assert_eq!(cursors.lower_bound(&hot_obj), 3);
2639        assert_eq!(
2640            schema.get_object_version_at_checkpoint(hot_obj, 0).unwrap(),
2641            None
2642        );
2643        assert_eq!(
2644            schema.get_object_version_at_checkpoint(hot_obj, 1).unwrap(),
2645            None
2646        );
2647        assert_eq!(
2648            schema.get_object_version_at_checkpoint(hot_obj, 2).unwrap(),
2649            None
2650        );
2651        assert_eq!(
2652            schema.get_object_version_at_checkpoint(hot_obj, 3).unwrap(),
2653            Some(ver(4))
2654        );
2655
2656        // Batch 3: prune through checkpoint 5 (new floor 6)
2657        let effects_b3 = vec![
2658            (4u64, cp4.transactions[0].effects.clone()),
2659            (5u64, cp5.transactions[0].effects.clone()),
2660        ];
2661        prune_history_cohort(&db, &schema, &mut cursors, 5, 0, &effects_b3).unwrap();
2662        assert_eq!(cursors.lower_bound(&hot_obj), 5);
2663        assert_eq!(
2664            schema.get_object_version_at_checkpoint(hot_obj, 3).unwrap(),
2665            None
2666        );
2667        assert_eq!(
2668            schema.get_object_version_at_checkpoint(hot_obj, 4).unwrap(),
2669            None
2670        );
2671        assert_eq!(
2672            schema.get_object_version_at_checkpoint(hot_obj, 6).unwrap(),
2673            Some(ver(6))
2674        );
2675    }
2676
2677    /// Eviction fallback: with a capacity of one, every batch's cursor
2678    /// advances evict each other, so the next batch's retractions run
2679    /// from the `lo_cp = 0` fallback — and must delete exactly the same
2680    /// rows a default-capacity (warm-cursor) run deletes. The default
2681    /// arm retaining more cursors than the tiny arm proves evictions
2682    /// actually happened.
2683    #[test]
2684    fn prune_history_cohort_eviction_fallback() {
2685        let (_dir_tiny, db_tiny, schema_tiny) = fresh_db();
2686        let (_dir_default, db_default, schema_default) = fresh_db();
2687
2688        // One continuous builder: cp0 creates three objects, cp1..cp3
2689        // each transfer all three, so every checkpoint records real
2690        // supersessions for all of them.
2691        let mut builder = TestCheckpointBuilder::new(0)
2692            .start_transaction(0)
2693            .create_owned_object(0)
2694            .create_owned_object(1)
2695            .create_owned_object(2)
2696            .finish_transaction();
2697        let cp0 = builder.build_checkpoint();
2698        builder = builder
2699            .start_transaction(0)
2700            .transfer_object(0, 1)
2701            .transfer_object(1, 1)
2702            .transfer_object(2, 1)
2703            .finish_transaction();
2704        let cp1 = builder.build_checkpoint();
2705        builder = builder
2706            .start_transaction(0)
2707            .transfer_object(0, 2)
2708            .transfer_object(1, 2)
2709            .transfer_object(2, 2)
2710            .finish_transaction();
2711        let cp2 = builder.build_checkpoint();
2712        builder = builder
2713            .start_transaction(0)
2714            .transfer_object(0, 3)
2715            .transfer_object(1, 3)
2716            .transfer_object(2, 3)
2717            .finish_transaction();
2718        let cp3 = builder.build_checkpoint();
2719
2720        let objs: Vec<ObjectID> = (0..3u64)
2721            .map(TestCheckpointBuilder::derive_object_id)
2722            .collect();
2723        let ver = |n: u64| sui_types::base_types::SequenceNumber::from_u64(n);
2724
2725        for (db, schema) in [(&db_tiny, &schema_tiny), (&db_default, &schema_default)] {
2726            let mut batch = db.batch();
2727            for &id in &objs {
2728                for cp in 0..4u64 {
2729                    let (k, v) = object_version_by_checkpoint::store(id, cp, ver(cp + 1));
2730                    batch
2731                        .put(&schema.object_version_by_checkpoint, &k, &v)
2732                        .unwrap();
2733                }
2734            }
2735            batch.commit().unwrap();
2736        }
2737
2738        let effects_b1: Vec<(u64, TransactionEffects)> = [(0u64, &cp0), (1, &cp1)]
2739            .into_iter()
2740            .flat_map(|(seq, cp)| {
2741                cp.transactions
2742                    .iter()
2743                    .map(move |tx| (seq, tx.effects.clone()))
2744            })
2745            .collect();
2746        let effects_b2: Vec<(u64, TransactionEffects)> = [(2u64, &cp2), (3, &cp3)]
2747            .into_iter()
2748            .flat_map(|(seq, cp)| {
2749                cp.transactions
2750                    .iter()
2751                    .map(move |tx| (seq, tx.effects.clone()))
2752            })
2753            .collect();
2754
2755        // Tiny arm: capacity 1 — each advance evicts the previous entry,
2756        // so batch 2 runs its scans from the checkpoint-0 fallback.
2757        let mut tiny_cursors = RetractionCursors::with_capacity(1);
2758        prune_history_cohort(&db_tiny, &schema_tiny, &mut tiny_cursors, 1, 0, &effects_b1).unwrap();
2759        assert!(
2760            objs.iter()
2761                .filter(|id| tiny_cursors.lower_bound(id) > 0)
2762                .count()
2763                <= 1,
2764            "capacity 1 must have evicted all but at most one cursor"
2765        );
2766        prune_history_cohort(&db_tiny, &schema_tiny, &mut tiny_cursors, 3, 0, &effects_b2).unwrap();
2767
2768        // Default arm: every cursor stays warm across batches.
2769        let mut default_cursors = RetractionCursors::default();
2770        prune_history_cohort(
2771            &db_default,
2772            &schema_default,
2773            &mut default_cursors,
2774            1,
2775            0,
2776            &effects_b1,
2777        )
2778        .unwrap();
2779        for id in &objs {
2780            assert_eq!(
2781                default_cursors.lower_bound(id),
2782                1,
2783                "default capacity keeps every cursor warm"
2784            );
2785        }
2786        prune_history_cohort(
2787            &db_default,
2788            &schema_default,
2789            &mut default_cursors,
2790            3,
2791            0,
2792            &effects_b2,
2793        )
2794        .unwrap();
2795
2796        // Evictions actually happened: the tiny arm holds one cursor
2797        // where the default arm holds one per recorded object.
2798        assert_eq!(tiny_cursors.len(), 1);
2799        assert!(default_cursors.len() > tiny_cursors.len());
2800
2801        // Fallback scans deleted exactly what warm scans deleted: only
2802        // each object's cp3 anchor survives, in both arms.
2803        let rows_tiny = all_object_version_by_checkpoint_rows(&schema_tiny);
2804        let rows_default = all_object_version_by_checkpoint_rows(&schema_default);
2805        assert_eq!(rows_tiny, rows_default);
2806        let mut expected: Vec<_> = objs
2807            .iter()
2808            .map(|&id| {
2809                (
2810                    object_version_by_checkpoint::Key { id, checkpoint: 3 },
2811                    ver(4),
2812                )
2813            })
2814            .collect();
2815        expected.sort_by_key(|(k, _)| k.id);
2816        assert_eq!(rows_tiny, expected);
2817    }
2818
2819    /// `removed` at or below cursor: an object removed at a checkpoint where
2820    /// the cursor is already at or above the removal checkpoint deletes the
2821    /// tombstone without resurrecting old rows or erroring.
2822    #[test]
2823    fn prune_history_cohort_removed_at_or_below_cursor() {
2824        let (_dir, db, schema) = fresh_db();
2825
2826        let mut builder = TestCheckpointBuilder::new(0)
2827            .start_transaction(0)
2828            .create_owned_object(0)
2829            .finish_transaction();
2830        let _cp0 = Arc::new(builder.build_checkpoint());
2831
2832        builder = builder
2833            .start_transaction(0)
2834            .transfer_object(0, 1)
2835            .finish_transaction()
2836            .start_transaction(0)
2837            .delete_object(0)
2838            .finish_transaction();
2839        let cp1 = Arc::new(builder.build_checkpoint());
2840
2841        let obj0 = TestCheckpointBuilder::derive_object_id(0);
2842        let ver = |n: u64| sui_types::base_types::SequenceNumber::from_u64(n);
2843
2844        let mut batch = db.batch();
2845        let (k0, v0) = object_version_by_checkpoint::store(obj0, 0, ver(1));
2846        let (k1, v1) = object_version_by_checkpoint::store(obj0, 1, ver(2));
2847        batch
2848            .put(&schema.object_version_by_checkpoint, &k0, &v0)
2849            .unwrap();
2850        batch
2851            .put(&schema.object_version_by_checkpoint, &k1, &v1)
2852            .unwrap();
2853        batch.commit().unwrap();
2854
2855        let mut cursors = RetractionCursors::default();
2856
2857        // Step 1: Supersession in cp1 retracts cp0 and sets cursor to 1.
2858        let effects_supersede: Vec<(u64, TransactionEffects)> =
2859            vec![(1u64, cp1.transactions[0].effects.clone())];
2860        prune_history_cohort(&db, &schema, &mut cursors, 0, 0, &effects_supersede).unwrap();
2861        assert_eq!(cursors.lower_bound(&obj0), 1);
2862        assert_eq!(
2863            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
2864            None
2865        );
2866        assert_eq!(
2867            schema.get_object_version_at_checkpoint(obj0, 1).unwrap(),
2868            Some(ver(2))
2869        );
2870
2871        // Step 2: Removal at cp1 where cursor is already 1 (cursor >= cp).
2872        let effects_remove: Vec<(u64, TransactionEffects)> =
2873            vec![(1u64, cp1.transactions[1].effects.clone())];
2874        prune_history_cohort(&db, &schema, &mut cursors, 1, 0, &effects_remove).unwrap();
2875        assert_eq!(cursors.lower_bound(&obj0), 1);
2876
2877        // The tombstone at cp1 is deleted, no resurrecting rows below cp1.
2878        assert_eq!(
2879            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
2880            None
2881        );
2882        assert_eq!(
2883            schema.get_object_version_at_checkpoint(obj0, 1).unwrap(),
2884            None
2885        );
2886        assert_eq!(
2887            schema.get_object_version_at_checkpoint(obj0, 2).unwrap(),
2888            None
2889        );
2890
2891        // Step 3: Idempotent re-run with cursor >= cp is a clean no-op without error.
2892        prune_history_cohort(&db, &schema, &mut cursors, 1, 0, &effects_remove).unwrap();
2893        assert_eq!(
2894            schema.get_object_version_at_checkpoint(obj0, 0).unwrap(),
2895            None
2896        );
2897        assert_eq!(
2898            schema.get_object_version_at_checkpoint(obj0, 1).unwrap(),
2899            None
2900        );
2901        assert_eq!(
2902            schema.get_object_version_at_checkpoint(obj0, 2).unwrap(),
2903            None
2904        );
2905    }
2906
2907    /// LRU recency: `advance` refreshes an entry's recency, so an object
2908    /// advanced in every batch stays resident under capacity pressure
2909    /// while one-shot cold objects are evicted around it. Capacity 4
2910    /// with three distinct objects per batch (one hot + two fresh colds)
2911    /// keeps eviction pressure on stale colds, never the hot entry.
2912    #[test]
2913    fn retraction_cursors_hot_object_survives_lru_churn() {
2914        let hot = TestCheckpointBuilder::derive_object_id(0);
2915        let mut cursors = RetractionCursors::with_capacity(4);
2916        for batch in 1..=5u64 {
2917            cursors.advance(hot, batch);
2918            for j in 0..2u64 {
2919                cursors.advance(
2920                    TestCheckpointBuilder::derive_object_id(100 + batch * 10 + j),
2921                    batch,
2922                );
2923            }
2924            assert_eq!(
2925                cursors.lower_bound(&hot),
2926                batch,
2927                "hot object must survive batch {batch}'s cold churn"
2928            );
2929        }
2930        // A cold object from the first batch fell back to the
2931        // checkpoint-0 default; the cache holds only the newest entries.
2932        assert_eq!(
2933            cursors.lower_bound(&TestCheckpointBuilder::derive_object_id(110)),
2934            0
2935        );
2936        assert_eq!(cursors.len(), 4);
2937    }
2938
2939    /// Stale-floor re-delivery with the cursor strictly above the
2940    /// effect's checkpoint (`lo_cp > cp`): the call passes the no-op
2941    /// gate on the tx axis alone, the retraction's scan range is empty,
2942    /// nothing is deleted, and the cursor holds its high-water mark.
2943    #[test]
2944    fn prune_history_cohort_stale_floor_redelivery_is_noop() {
2945        let (_dir, db, schema) = fresh_db();
2946
2947        let mut builder = TestCheckpointBuilder::new(0)
2948            .start_transaction(0)
2949            .create_owned_object(0)
2950            .finish_transaction();
2951        let cp0 = builder.build_checkpoint();
2952        builder = builder
2953            .start_transaction(0)
2954            .transfer_object(0, 1)
2955            .finish_transaction();
2956        let cp1 = builder.build_checkpoint();
2957        builder = builder
2958            .start_transaction(0)
2959            .transfer_object(0, 2)
2960            .finish_transaction();
2961        let cp2 = builder.build_checkpoint();
2962        builder = builder
2963            .start_transaction(0)
2964            .transfer_object(0, 3)
2965            .finish_transaction();
2966        let cp3 = builder.build_checkpoint();
2967
2968        let obj0 = TestCheckpointBuilder::derive_object_id(0);
2969        let ver = |n: u64| sui_types::base_types::SequenceNumber::from_u64(n);
2970
2971        let mut batch = db.batch();
2972        for cp in 0..4u64 {
2973            let (k, v) = object_version_by_checkpoint::store(obj0, cp, ver(cp + 1));
2974            batch
2975                .put(&schema.object_version_by_checkpoint, &k, &v)
2976                .unwrap();
2977        }
2978        batch.commit().unwrap();
2979
2980        // Retract through cp3: rows below 3 deleted, the cp3 anchor
2981        // kept, cursor at 3.
2982        let effects_all: Vec<(u64, TransactionEffects)> =
2983            [(0u64, &cp0), (1, &cp1), (2, &cp2), (3, &cp3)]
2984                .into_iter()
2985                .flat_map(|(seq, cp)| {
2986                    cp.transactions
2987                        .iter()
2988                        .map(move |tx| (seq, tx.effects.clone()))
2989                })
2990                .collect();
2991        let mut cursors = RetractionCursors::default();
2992        prune_history_cohort(&db, &schema, &mut cursors, 3, 0, &effects_all).unwrap();
2993        assert_eq!(cursors.lower_bound(&obj0), 3);
2994        assert_eq!(
2995            schema.get_object_version_at_checkpoint(obj0, 3).unwrap(),
2996            Some(ver(4))
2997        );
2998
2999        // Re-deliver cp1's effect with a stale checkpoint floor; the tx
3000        // axis advances, so the call passes the no-op gate and reaches
3001        // the retraction with `lo_cp = 3 > cp = 1`.
3002        let redelivered: Vec<(u64, TransactionEffects)> =
3003            vec![(1, cp1.transactions[0].effects.clone())];
3004        prune_history_cohort(&db, &schema, &mut cursors, 1, 5, &redelivered).unwrap();
3005
3006        // No deletion, the anchor survives, and the cursor held its
3007        // high-water mark instead of regressing to 1.
3008        assert_eq!(cursors.lower_bound(&obj0), 3);
3009        assert_eq!(
3010            schema.get_object_version_at_checkpoint(obj0, 3).unwrap(),
3011            Some(ver(4))
3012        );
3013        let rows = all_object_version_by_checkpoint_rows(&schema);
3014        assert_eq!(
3015            rows,
3016            vec![(
3017                object_version_by_checkpoint::Key {
3018                    id: obj0,
3019                    checkpoint: 3
3020                },
3021                ver(4)
3022            )]
3023        );
3024    }
3025
3026    /// Standalone `prune_chunk` path coverage: verifies cursor advance,
3027    /// differential consistency, and simulated restart across multiple chunks.
3028    #[tokio::test]
3029    async fn prune_chunk_standalone_retraction_cursors_and_restart() {
3030        use crate::indexer::object_version_by_checkpoint::ObjectVersionByCheckpoint;
3031
3032        let (_dir1, db1, schema1) = fresh_db();
3033        let (_dir2, db2, schema2) = fresh_db();
3034        let (_dir3, db3, schema3) = fresh_db();
3035
3036        let mut builder = TestCheckpointBuilder::new(0)
3037            .start_transaction(0)
3038            .create_owned_object(0)
3039            .create_owned_object(1)
3040            .finish_transaction();
3041        let cp0 = Arc::new(builder.build_checkpoint());
3042
3043        builder = builder
3044            .start_transaction(0)
3045            .transfer_object(0, 1)
3046            .transfer_object(1, 2)
3047            .finish_transaction();
3048        let cp1 = Arc::new(builder.build_checkpoint());
3049
3050        builder = builder
3051            .start_transaction(0)
3052            .transfer_object(0, 2)
3053            .delete_object(1)
3054            .finish_transaction();
3055        let cp2 = Arc::new(builder.build_checkpoint());
3056
3057        builder = builder
3058            .start_transaction(0)
3059            .transfer_object(0, 3)
3060            .finish_transaction();
3061        let cp3 = Arc::new(builder.build_checkpoint());
3062
3063        let checkpoints = [&cp0, &cp1, &cp2, &cp3];
3064
3065        for (db, schema) in [(&db1, &schema1), (&db2, &schema2), (&db3, &schema3)] {
3066            for cp in &checkpoints {
3067                seed(db, schema, cp).await;
3068                let mut batch = db.batch();
3069                for row in ObjectVersionByCheckpoint::default()
3070                    .process(cp)
3071                    .await
3072                    .unwrap()
3073                {
3074                    let crate::indexer::object_version_by_checkpoint::Row::Change {
3075                        id,
3076                        checkpoint,
3077                        version,
3078                    } = row
3079                    else {
3080                        continue;
3081                    };
3082                    let (k, v) = object_version_by_checkpoint::store(id, checkpoint, version);
3083                    batch
3084                        .put(&schema.object_version_by_checkpoint, &k, &v)
3085                        .unwrap();
3086                }
3087                batch.commit().unwrap();
3088            }
3089        }
3090
3091        let metrics = PrunerMetrics::new(None, &Registry::new());
3092
3093        // Run 1: Continuous cursors across chunks
3094        let mut cursors1 = RetractionCursors::default();
3095        let w1 = prune_chunk(
3096            &db1,
3097            &schema1,
3098            &mut cursors1,
3099            Watermarks::default(),
3100            2,
3101            &metrics,
3102        )
3103        .unwrap();
3104        prune_chunk(&db1, &schema1, &mut cursors1, w1, 4, &metrics).unwrap();
3105
3106        // Run 2: Fresh cursors per chunk
3107        let w2 = prune_chunk(
3108            &db2,
3109            &schema2,
3110            &mut RetractionCursors::default(),
3111            Watermarks::default(),
3112            2,
3113            &metrics,
3114        )
3115        .unwrap();
3116        prune_chunk(
3117            &db2,
3118            &schema2,
3119            &mut RetractionCursors::default(),
3120            w2,
3121            4,
3122            &metrics,
3123        )
3124        .unwrap();
3125
3126        // Run 3: Simulated restart between chunks
3127        let mut cursors3 = RetractionCursors::default();
3128        let w3 = prune_chunk(
3129            &db3,
3130            &schema3,
3131            &mut cursors3,
3132            Watermarks::default(),
3133            2,
3134            &metrics,
3135        )
3136        .unwrap();
3137        cursors3 = RetractionCursors::default();
3138        prune_chunk(&db3, &schema3, &mut cursors3, w3, 4, &metrics).unwrap();
3139
3140        let rows1 = all_object_version_by_checkpoint_rows(&schema1);
3141        let rows2 = all_object_version_by_checkpoint_rows(&schema2);
3142        let rows3 = all_object_version_by_checkpoint_rows(&schema3);
3143
3144        assert_eq!(rows1, rows2);
3145        assert_eq!(rows1, rows3);
3146        assert!(!rows1.is_empty());
3147    }
3148}