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