Skip to main content

sui_core/authority/
authority_store_pruner.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use super::authority_store_tables::AuthorityPerpetualTables;
5use crate::checkpoints::{CheckpointStore, CheckpointWatermark};
6use anyhow::anyhow;
7use mysten_metrics::monitored_scope;
8#[cfg(not(tidehunter))]
9use mysten_metrics::spawn_monitored_task;
10#[cfg(not(tidehunter))]
11use once_cell::sync::Lazy;
12use prometheus::{
13    IntCounter, IntGauge, Registry, register_int_counter_with_registry,
14    register_int_gauge_with_registry,
15};
16#[cfg(tidehunter)]
17use serde::de::DeserializeOwned;
18#[cfg(not(tidehunter))]
19use std::cmp::max;
20use std::cmp::min;
21#[cfg(not(tidehunter))]
22use std::collections::{BTreeSet, HashMap};
23#[cfg(not(tidehunter))]
24use std::sync::Mutex;
25use std::sync::atomic::AtomicU64;
26#[cfg(not(tidehunter))]
27use std::time::{SystemTime, UNIX_EPOCH};
28use std::{sync::Arc, time::Duration};
29use sui_config::node::AuthorityStorePruningConfig;
30pub use sui_rpc_store::RetractionCursors;
31use sui_rpc_store::Store as RpcStore;
32#[cfg(not(tidehunter))]
33use sui_types::base_types::VersionNumber;
34use sui_types::committee::EpochId;
35use sui_types::effects::TransactionEffects;
36use sui_types::effects::TransactionEffectsAPI;
37use sui_types::message_envelope::Message;
38use sui_types::messages_checkpoint::{
39    CheckpointContents, CheckpointDigest, CheckpointSequenceNumber,
40};
41use sui_types::{
42    base_types::{ObjectID, SequenceNumber, TransactionDigest},
43    storage::ObjectKey,
44};
45use tokio::sync::oneshot::{self, Sender};
46use tokio::time::Instant;
47use tracing::{debug, error, info, warn};
48#[cfg(not(tidehunter))]
49use typed_store::rocksdb::LiveFile;
50use typed_store::{Map, TypedStoreError};
51
52#[cfg(not(tidehunter))]
53static PERIODIC_PRUNING_TABLES: Lazy<BTreeSet<String>> = Lazy::new(|| {
54    [
55        "objects",
56        "effects",
57        "transactions",
58        "events",
59        "executed_effects",
60        "executed_transactions_to_checkpoint",
61    ]
62    .into_iter()
63    .map(|cf| cf.to_string())
64    .collect()
65});
66pub const EPOCH_DURATION_MS_FOR_TESTING: u64 = 24 * 60 * 60 * 1000;
67pub struct AuthorityStorePruner {
68    _objects_pruner_cancel_handle: oneshot::Sender<()>,
69}
70
71#[derive(Default)]
72pub struct PrunerWatermarks {
73    pub epoch_id: Arc<AtomicU64>,
74    pub checkpoint_id: Arc<AtomicU64>,
75}
76
77static MIN_PRUNING_TICK_DURATION_MS: u64 = 10 * 1000;
78
79pub struct AuthorityStorePruningMetrics {
80    pub last_pruned_checkpoint: IntGauge,
81    pub num_pruned_objects: IntCounter,
82    pub num_pruned_tombstones: IntCounter,
83    pub last_pruned_effects_checkpoint: IntGauge,
84    pub num_epochs_to_retain_for_objects: IntGauge,
85    pub num_epochs_to_retain_for_checkpoints: IntGauge,
86    pub last_pruned_executed_tx_digests_epoch: IntGauge,
87}
88
89impl AuthorityStorePruningMetrics {
90    pub fn new(registry: &Registry) -> Arc<Self> {
91        let this = Self {
92            last_pruned_checkpoint: register_int_gauge_with_registry!(
93                "last_pruned_checkpoint",
94                "Last pruned checkpoint",
95                registry
96            )
97            .unwrap(),
98            num_pruned_objects: register_int_counter_with_registry!(
99                "num_pruned_objects",
100                "Number of pruned objects",
101                registry
102            )
103            .unwrap(),
104            num_pruned_tombstones: register_int_counter_with_registry!(
105                "num_pruned_tombstones",
106                "Number of pruned tombstones",
107                registry
108            )
109            .unwrap(),
110            last_pruned_effects_checkpoint: register_int_gauge_with_registry!(
111                "last_pruned_effects_checkpoint",
112                "Last pruned effects checkpoint",
113                registry
114            )
115            .unwrap(),
116            num_epochs_to_retain_for_objects: register_int_gauge_with_registry!(
117                "num_epochs_to_retain_for_objects",
118                "Number of epochs to retain for objects",
119                registry
120            )
121            .unwrap(),
122            num_epochs_to_retain_for_checkpoints: register_int_gauge_with_registry!(
123                "num_epochs_to_retain_for_checkpoints",
124                "Number of epochs to retain for checkpoints",
125                registry
126            )
127            .unwrap(),
128            last_pruned_executed_tx_digests_epoch: register_int_gauge_with_registry!(
129                "last_pruned_executed_tx_digests_epoch",
130                "Highest epoch whose executed_transaction_digests entries have been pruned",
131                registry
132            )
133            .unwrap(),
134        };
135        Arc::new(this)
136    }
137
138    pub fn new_for_test() -> Arc<Self> {
139        Self::new(&Registry::new())
140    }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq)]
144pub enum PruningMode {
145    Objects,
146    Checkpoints,
147}
148
149impl AuthorityStorePruner {
150    /// prunes old versions of objects based on transaction effects
151    #[cfg(not(tidehunter))]
152    async fn prune_objects_and_indexes(
153        transaction_effects: Vec<(CheckpointSequenceNumber, TransactionEffects)>,
154        perpetual_db: &Arc<AuthorityPerpetualTables>,
155        checkpoint_number: CheckpointSequenceNumber,
156        metrics: Arc<AuthorityStorePruningMetrics>,
157        pruned_tx_seq_exclusive: u64,
158        rpc_store: Option<&RpcStore>,
159        retraction_cursors: &mut RetractionCursors,
160        enable_pruning_tombstones: bool,
161    ) -> anyhow::Result<()> {
162        let _scope = monitored_scope("ObjectsLivePruner");
163        let mut wb = perpetual_db.objects.batch();
164
165        // Collect objects keys that need to be deleted from `transaction_effects`.
166        let mut live_object_keys_to_prune = vec![];
167        let mut object_tombstones_to_prune = vec![];
168        for (_checkpoint, effects) in &transaction_effects {
169            for (object_id, seq_number) in effects.modified_at_versions() {
170                live_object_keys_to_prune.push(ObjectKey(object_id, seq_number));
171            }
172
173            if enable_pruning_tombstones {
174                for deleted_object_key in effects.all_tombstones() {
175                    object_tombstones_to_prune
176                        .push(ObjectKey(deleted_object_key.0, deleted_object_key.1));
177                }
178            }
179        }
180
181        metrics
182            .num_pruned_objects
183            .inc_by(live_object_keys_to_prune.len() as u64);
184        metrics
185            .num_pruned_tombstones
186            .inc_by(object_tombstones_to_prune.len() as u64);
187
188        let mut updates: HashMap<ObjectID, (VersionNumber, VersionNumber)> = HashMap::new();
189        for ObjectKey(object_id, seq_number) in live_object_keys_to_prune {
190            updates
191                .entry(object_id)
192                .and_modify(|range| *range = (min(range.0, seq_number), max(range.1, seq_number)))
193                .or_insert((seq_number, seq_number));
194        }
195
196        for (object_id, (min_version, max_version)) in updates {
197            debug!(
198                "Pruning object {:?} versions {:?} - {:?}",
199                object_id, min_version, max_version
200            );
201            let start_range = ObjectKey(object_id, min_version);
202            let end_range = ObjectKey(object_id, (max_version.value() + 1).into());
203            wb.schedule_delete_range(&perpetual_db.objects, &start_range, &end_range)?;
204        }
205
206        // When enable_pruning_tombstones is enabled, instead of using range deletes, we need to do a scan of all the keys
207        // for the deleted objects and then do point deletes to delete all the existing keys. This is because to improve read
208        // performance, we set `ignore_range_deletions` on all read options, and using range delete to delete tombstones
209        // may leak object (imagine a tombstone is compacted away, but earlier version is still not). Using point deletes
210        // guarantees that all earlier versions are deleted in the database.
211        if !object_tombstones_to_prune.is_empty() {
212            let mut object_keys_to_delete = vec![];
213            for ObjectKey(object_id, seq_number) in object_tombstones_to_prune {
214                for result in perpetual_db.objects.safe_iter_with_bounds(
215                    Some(ObjectKey(object_id, VersionNumber::MIN)),
216                    Some(ObjectKey(object_id, seq_number.next())),
217                ) {
218                    let (object_key, _) = result?;
219                    assert_eq!(object_key.0, object_id);
220                    object_keys_to_delete.push(object_key);
221                }
222            }
223
224            wb.delete_batch(&perpetual_db.objects, object_keys_to_delete)?;
225        }
226
227        perpetual_db.set_highest_pruned_checkpoint(&mut wb, checkpoint_number)?;
228        metrics.last_pruned_checkpoint.set(checkpoint_number as i64);
229
230        // Prune the embedded rpc-store's history cohort to the same floor
231        // BEFORE committing the perpetual batch. The rpc-store's
232        // `object_version_by_checkpoint` retraction is driven by the
233        // effects passed here; if the perpetual floor committed first, a
234        // crash between the two commits would resume the pruner past these
235        // checkpoints and their effects would never be replayed, leaking
236        // the rpc-store rows they retract. In this order a crash re-reads
237        // the same checkpoints on restart (the perpetual floor has not
238        // moved) and `prune_history_cohort` re-runs as an idempotent
239        // no-op.
240        if let Some(rpc_store) = rpc_store {
241            sui_rpc_store::prune_history_cohort(
242                rpc_store.db(),
243                rpc_store.schema(),
244                retraction_cursors,
245                checkpoint_number,
246                pruned_tx_seq_exclusive,
247                &transaction_effects,
248            )?;
249        }
250
251        wb.write()?;
252
253        Ok(())
254    }
255
256    #[cfg(tidehunter)]
257    async fn prune_objects_and_indexes(
258        transaction_effects: Vec<(CheckpointSequenceNumber, TransactionEffects)>,
259        perpetual_db: &Arc<AuthorityPerpetualTables>,
260        checkpoint_number: CheckpointSequenceNumber,
261        metrics: Arc<AuthorityStorePruningMetrics>,
262        pruned_tx_seq_exclusive: u64,
263        rpc_store: Option<&RpcStore>,
264        retraction_cursors: &mut RetractionCursors,
265        _: bool,
266    ) -> anyhow::Result<()> {
267        let _scope = monitored_scope("ObjectsLivePruner");
268        let mut wb = perpetual_db.objects.batch();
269        let mut objects_to_prune = vec![];
270
271        for (_checkpoint, effects) in &transaction_effects {
272            for (object_id, version) in effects
273                .modified_at_versions()
274                .into_iter()
275                .chain(effects.all_tombstones())
276            {
277                debug!("Pruning object {:?} version {:?}", object_id, version);
278                objects_to_prune.push(ObjectKey(object_id, version));
279            }
280        }
281        metrics
282            .num_pruned_objects
283            .inc_by(objects_to_prune.len() as u64);
284        wb.delete_batch(&perpetual_db.objects, &objects_to_prune)?;
285
286        perpetual_db.set_highest_pruned_checkpoint(&mut wb, checkpoint_number)?;
287        metrics.last_pruned_checkpoint.set(checkpoint_number as i64);
288
289        // Prune the embedded rpc-store's history cohort BEFORE committing
290        // the perpetual batch; see the rocksdb variant above for the
291        // crash-ordering rationale.
292        if let Some(rpc_store) = rpc_store {
293            sui_rpc_store::prune_history_cohort(
294                rpc_store.db(),
295                rpc_store.schema(),
296                retraction_cursors,
297                checkpoint_number,
298                pruned_tx_seq_exclusive,
299                &transaction_effects,
300            )?;
301        }
302
303        wb.write()?;
304        Ok(())
305    }
306
307    fn prune_checkpoints(
308        perpetual_db: &Arc<AuthorityPerpetualTables>,
309        checkpoint_db: &Arc<CheckpointStore>,
310        checkpoint_number: CheckpointSequenceNumber,
311        checkpoints_to_prune: Vec<CheckpointDigest>,
312        checkpoint_content_to_prune: Vec<CheckpointContents>,
313        effects_to_prune: &Vec<(CheckpointSequenceNumber, TransactionEffects)>,
314        metrics: Arc<AuthorityStorePruningMetrics>,
315    ) -> anyhow::Result<()> {
316        let _scope = monitored_scope("EffectsLivePruner");
317
318        let mut perpetual_batch = perpetual_db.objects.batch();
319        let transactions: Vec<_> = checkpoint_content_to_prune
320            .iter()
321            .flat_map(|content| content.iter().map(|tx| tx.transaction))
322            .collect();
323
324        perpetual_batch.delete_batch(&perpetual_db.transactions, transactions.iter())?;
325        perpetual_batch.delete_batch(&perpetual_db.executed_effects, transactions.iter())?;
326        perpetual_batch.delete_batch(
327            &perpetual_db.executed_transactions_to_checkpoint,
328            transactions.iter(),
329        )?;
330
331        let mut effect_digests = vec![];
332        for (_checkpoint, effects) in effects_to_prune {
333            let effects_digest = effects.digest();
334            debug!("Pruning effects {:?}", effects_digest);
335            effect_digests.push(effects_digest);
336
337            if effects.events_digest().is_some() {
338                perpetual_batch
339                    .delete_batch(&perpetual_db.events_2, [effects.transaction_digest()])?;
340            }
341        }
342        perpetual_batch.delete_batch(
343            &perpetual_db.unchanged_loaded_runtime_objects,
344            transactions.iter(),
345        )?;
346        perpetual_batch.delete_batch(&perpetual_db.effects, effect_digests)?;
347
348        let mut checkpoints_batch = checkpoint_db.tables.certified_checkpoints.batch();
349
350        let checkpoint_content_digests =
351            checkpoint_content_to_prune.iter().map(|ckpt| ckpt.digest());
352        checkpoints_batch.delete_batch(
353            &checkpoint_db.tables.checkpoint_content,
354            checkpoint_content_digests.clone(),
355        )?;
356        checkpoints_batch.delete_batch(
357            &checkpoint_db.tables.checkpoint_sequence_by_contents_digest,
358            checkpoint_content_digests,
359        )?;
360
361        checkpoints_batch.delete_batch(
362            &checkpoint_db.tables.checkpoint_by_digest,
363            checkpoints_to_prune,
364        )?;
365
366        checkpoints_batch.insert_batch(
367            &checkpoint_db.tables.watermarks,
368            [(
369                &CheckpointWatermark::HighestPruned,
370                &(checkpoint_number, CheckpointDigest::random()),
371            )],
372        )?;
373
374        perpetual_batch.write()?;
375        checkpoints_batch.write()?;
376        metrics
377            .last_pruned_effects_checkpoint
378            .set(checkpoint_number as i64);
379
380        Ok(())
381    }
382
383    /// The exclusive upper bound the embedded rpc-store imposes on the
384    /// pruner's eligible range: one past the highest checkpoint every
385    /// embedded-cohort pipeline has committed
386    /// ([`sui_rpc_store::embedded_prunable_checkpoint`]). The indexer's
387    /// backfill and gap-fill assemble full checkpoints from the
388    /// perpetual and checkpoint stores, so pruning a checkpoint's data
389    /// (contents, effects, or the object versions its transactions
390    /// read) before every pipeline has committed it would stall the
391    /// indexer permanently on a checkpoint that can no longer be
392    /// served. `u64::MAX` (no bound) when no embedded store is
393    /// configured; `0` (nothing eligible) while any cohort pipeline
394    /// has no watermark yet.
395    fn rpc_store_max_eligible_checkpoint(
396        rpc_store: Option<&RpcStore>,
397    ) -> anyhow::Result<CheckpointSequenceNumber> {
398        let Some(rpc_store) = rpc_store else {
399            return Ok(u64::MAX);
400        };
401        let indexed = sui_rpc_store::embedded_prunable_checkpoint(rpc_store.db())?;
402        // The eligible bound is exclusive, so `indexed + 1` still
403        // allows pruning the committed checkpoint itself.
404        Ok(indexed.map_or(0, |c| c.saturating_add(1)))
405    }
406
407    /// Prunes old data based on effects from all checkpoints from epochs eligible for pruning
408    pub async fn prune_objects_for_eligible_epochs(
409        perpetual_db: &Arc<AuthorityPerpetualTables>,
410        checkpoint_store: &Arc<CheckpointStore>,
411        rpc_store: Option<&RpcStore>,
412        retraction_cursors: &mut RetractionCursors,
413        config: AuthorityStorePruningConfig,
414        metrics: Arc<AuthorityStorePruningMetrics>,
415        epoch_duration_ms: u64,
416    ) -> anyhow::Result<()> {
417        let _scope = monitored_scope("PruneObjectsForEligibleEpochs");
418        let (mut max_eligible_checkpoint_number, epoch_id) = checkpoint_store
419            .get_highest_executed_checkpoint()?
420            .map(|c| (*c.sequence_number(), c.epoch))
421            .unwrap_or_default();
422        let pruned_checkpoint_number = perpetual_db
423            .get_highest_pruned_checkpoint()?
424            .unwrap_or_default();
425        if config.smooth && config.num_epochs_to_retain > 0 {
426            max_eligible_checkpoint_number = Self::smoothed_max_eligible_checkpoint_number(
427                checkpoint_store,
428                max_eligible_checkpoint_number,
429                pruned_checkpoint_number,
430                epoch_id,
431                epoch_duration_ms,
432                config.num_epochs_to_retain,
433            )?;
434        }
435        let rpc_store_bound = Self::rpc_store_max_eligible_checkpoint(rpc_store)?;
436        if rpc_store_bound < max_eligible_checkpoint_number {
437            info!(
438                "objects pruning gated by the embedded rpc-store indexer: \
439                 max eligible checkpoint {} -> {}",
440                max_eligible_checkpoint_number, rpc_store_bound,
441            );
442            max_eligible_checkpoint_number = rpc_store_bound;
443        }
444        Self::prune_for_eligible_epochs(
445            perpetual_db,
446            checkpoint_store,
447            rpc_store,
448            retraction_cursors,
449            PruningMode::Objects,
450            config.num_epochs_to_retain,
451            pruned_checkpoint_number,
452            max_eligible_checkpoint_number,
453            config,
454            metrics.clone(),
455        )
456        .await
457    }
458
459    pub async fn prune_checkpoints_for_eligible_epochs(
460        perpetual_db: &Arc<AuthorityPerpetualTables>,
461        checkpoint_store: &Arc<CheckpointStore>,
462        rpc_store: Option<&RpcStore>,
463        config: AuthorityStorePruningConfig,
464        metrics: Arc<AuthorityStorePruningMetrics>,
465        epoch_duration_ms: u64,
466        pruner_watermarks: &Arc<PrunerWatermarks>,
467    ) -> anyhow::Result<()> {
468        let _scope = monitored_scope("PruneCheckpointsForEligibleEpochs");
469        let pruned_checkpoint_number = checkpoint_store
470            .get_highest_pruned_checkpoint_seq_number()?
471            .unwrap_or(0);
472        let (mut max_eligible_checkpoint, epoch_id) = checkpoint_store
473            .get_highest_executed_checkpoint()?
474            .map(|c| (*c.sequence_number(), c.epoch))
475            .unwrap_or_default();
476        if config.num_epochs_to_retain != u64::MAX {
477            max_eligible_checkpoint = min(
478                max_eligible_checkpoint,
479                perpetual_db
480                    .get_highest_pruned_checkpoint()?
481                    .unwrap_or_default(),
482            );
483        }
484        if config.smooth
485            && let Some(num_epochs_to_retain) = config.num_epochs_to_retain_for_checkpoints
486        {
487            max_eligible_checkpoint = Self::smoothed_max_eligible_checkpoint_number(
488                checkpoint_store,
489                max_eligible_checkpoint,
490                pruned_checkpoint_number,
491                epoch_id,
492                epoch_duration_ms,
493                num_epochs_to_retain,
494            )?;
495        }
496        // With `num_epochs_to_retain == u64::MAX` the objects floor
497        // never advances, so the clamp above does not apply and the
498        // rpc-store bound is the only thing keeping checkpoint contents
499        // around for the indexer; apply it in both cases regardless.
500        let rpc_store_bound = Self::rpc_store_max_eligible_checkpoint(rpc_store)?;
501        if rpc_store_bound < max_eligible_checkpoint {
502            info!(
503                "checkpoint pruning gated by the embedded rpc-store indexer: \
504                 max eligible checkpoint {} -> {}",
505                max_eligible_checkpoint, rpc_store_bound,
506            );
507            max_eligible_checkpoint = rpc_store_bound;
508        }
509        debug!("Max eligible checkpoint {}", max_eligible_checkpoint);
510        Self::prune_for_eligible_epochs(
511            perpetual_db,
512            checkpoint_store,
513            rpc_store,
514            // Checkpoints mode only prunes checkpoint tables and never calls
515            // `prune_objects_and_indexes`; the default cursor is inert.
516            &mut RetractionCursors::default(),
517            PruningMode::Checkpoints,
518            config
519                .num_epochs_to_retain_for_checkpoints()
520                .ok_or_else(|| anyhow!("config value not set"))?,
521            pruned_checkpoint_number,
522            max_eligible_checkpoint,
523            config.clone(),
524            metrics.clone(),
525        )
526        .await?;
527
528        if let Some(num_epochs_to_retain) = config.num_epochs_to_retain_for_checkpoints() {
529            Self::update_pruning_watermarks(
530                perpetual_db,
531                checkpoint_store,
532                num_epochs_to_retain,
533                pruner_watermarks,
534                false,
535            )?;
536        }
537        Ok(())
538    }
539
540    /// Prunes old object versions based on effects from all checkpoints from epochs eligible for pruning
541    pub async fn prune_for_eligible_epochs(
542        perpetual_db: &Arc<AuthorityPerpetualTables>,
543        checkpoint_store: &Arc<CheckpointStore>,
544        rpc_store: Option<&RpcStore>,
545        retraction_cursors: &mut RetractionCursors,
546        mode: PruningMode,
547        num_epochs_to_retain: u64,
548        starting_checkpoint_number: CheckpointSequenceNumber,
549        max_eligible_checkpoint: CheckpointSequenceNumber,
550        config: AuthorityStorePruningConfig,
551        metrics: Arc<AuthorityStorePruningMetrics>,
552    ) -> anyhow::Result<()> {
553        let _scope = monitored_scope("PruneForEligibleEpochs");
554
555        let mut checkpoint_number = starting_checkpoint_number;
556        let current_epoch = checkpoint_store
557            .get_highest_executed_checkpoint()?
558            .map(|c| c.epoch())
559            .unwrap_or_default();
560
561        let mut checkpoints_to_prune = vec![];
562        let mut checkpoint_content_to_prune = vec![];
563        // Each effect is tagged with the checkpoint it is pruned from so the
564        // embedded rpc-store's `object_version_by_checkpoint` retraction can
565        // keep each object's anchor at its true supersession checkpoint.
566        let mut effects_to_prune: Vec<(CheckpointSequenceNumber, TransactionEffects)> = vec![];
567        // Absolute tx-seq floor (exclusive) after pruning the current
568        // batch — the last-pruned checkpoint's `network_total_transactions`.
569        // The embedded rpc-store's history-cohort prune consumes this
570        // directly instead of summing checkpoint content sizes.
571        let mut pruned_tx_seq_exclusive = 0u64;
572
573        while let Some(ckpt) = checkpoint_store
574            .tables
575            .certified_checkpoints
576            .get(&(checkpoint_number + 1))?
577        {
578            let checkpoint = ckpt.into_inner();
579            // Skipping because  checkpoint's epoch or checkpoint number is too new.
580            // We have to respect the highest executed checkpoint watermark (including the watermark itself)
581            // because there might be parts of the system that still require access to old object versions
582            // (i.e. state accumulator).
583            if (current_epoch < checkpoint.epoch() + num_epochs_to_retain)
584                || (*checkpoint.sequence_number() >= max_eligible_checkpoint)
585            {
586                break;
587            }
588            checkpoint_number = *checkpoint.sequence_number();
589            pruned_tx_seq_exclusive = checkpoint.network_total_transactions;
590
591            let content = checkpoint_store
592                .get_checkpoint_contents(&checkpoint.content_digest)?
593                .ok_or_else(|| {
594                    anyhow::anyhow!(
595                        "checkpoint content data is missing: {}",
596                        checkpoint.sequence_number
597                    )
598                })?;
599            let effects = perpetual_db
600                .effects
601                .multi_get(content.iter().map(|tx| tx.effects))?;
602
603            info!("scheduling pruning for checkpoint {:?}", checkpoint_number);
604            checkpoints_to_prune.push(*checkpoint.digest());
605            checkpoint_content_to_prune.push(content);
606            effects_to_prune.extend(
607                effects
608                    .into_iter()
609                    .flatten()
610                    .map(|effects| (checkpoint_number, effects)),
611            );
612
613            if effects_to_prune.len() >= config.max_transactions_in_batch
614                || checkpoints_to_prune.len() >= config.max_checkpoints_in_batch
615            {
616                match mode {
617                    PruningMode::Objects => {
618                        Self::prune_objects_and_indexes(
619                            effects_to_prune,
620                            perpetual_db,
621                            checkpoint_number,
622                            metrics.clone(),
623                            pruned_tx_seq_exclusive,
624                            rpc_store,
625                            retraction_cursors,
626                            !config.killswitch_tombstone_pruning,
627                        )
628                        .await?
629                    }
630                    PruningMode::Checkpoints => Self::prune_checkpoints(
631                        perpetual_db,
632                        checkpoint_store,
633                        checkpoint_number,
634                        checkpoints_to_prune,
635                        checkpoint_content_to_prune,
636                        &effects_to_prune,
637                        metrics.clone(),
638                    )?,
639                };
640                checkpoints_to_prune = vec![];
641                checkpoint_content_to_prune = vec![];
642                effects_to_prune = vec![];
643                // yield back to the tokio runtime. Prevent potential halt of other tasks
644                tokio::task::yield_now().await;
645            }
646        }
647
648        if !checkpoints_to_prune.is_empty() {
649            match mode {
650                PruningMode::Objects => {
651                    Self::prune_objects_and_indexes(
652                        effects_to_prune,
653                        perpetual_db,
654                        checkpoint_number,
655                        metrics.clone(),
656                        pruned_tx_seq_exclusive,
657                        rpc_store,
658                        retraction_cursors,
659                        !config.killswitch_tombstone_pruning,
660                    )
661                    .await?
662                }
663                PruningMode::Checkpoints => Self::prune_checkpoints(
664                    perpetual_db,
665                    checkpoint_store,
666                    checkpoint_number,
667                    checkpoints_to_prune,
668                    checkpoint_content_to_prune,
669                    &effects_to_prune,
670                    metrics.clone(),
671                )?,
672            };
673        }
674        Ok(())
675    }
676
677    /// Deletes `executed_transaction_digests` entries for every epoch <= current_epoch - 2.
678    ///
679    /// Only the current and previous epochs are ever read from this table
680    /// (`was_transaction_executed_in_last_epoch` and the startup check in
681    /// `AuthorityState::new`), so this runs on every node independently of the
682    /// history-retention settings (`num_epochs_to_retain*`). Without it the table grows
683    /// without bound; on tidehunter each epoch additionally pins ~4096 cells with an
684    /// in-memory bloom filter each, which is what exhausts memory on unpruned nodes.
685    ///
686    /// The cutoff is derived from the highest *executed* checkpoint, whose epoch never
687    /// exceeds the epoch store's current epoch (checkpoints of epoch N+1 are only executed
688    /// after reconfiguring to N+1), so epoch `epoch_store.epoch() - 1` always survives even
689    /// though the first tick fires before the startup check. On tidehunter the relocation
690    /// filter on this table (`AuthorityPerpetualTables::open`) independently removes
691    /// entries below the checkpoint-retention watermark; that is a subset of what is
692    /// dropped here and both keep the current and previous epoch intact.
693    ///
694    /// `last_pruned_epoch` short-circuits the call when the epoch has not advanced since
695    /// the previous invocation, so the delete is issued once per epoch rather than on
696    /// every tick, and narrows later deletes to the epochs that became eligible since.
697    fn prune_executed_tx_digests(
698        perpetual_db: &Arc<AuthorityPerpetualTables>,
699        checkpoint_store: &Arc<CheckpointStore>,
700        metrics: &AuthorityStorePruningMetrics,
701        last_pruned_epoch: &mut Option<EpochId>,
702    ) -> anyhow::Result<()> {
703        let current_epoch = checkpoint_store
704            .get_highest_executed_checkpoint()?
705            .map(|c| c.epoch)
706            .unwrap_or_default();
707
708        if current_epoch < 2 || last_pruned_epoch.is_some_and(|e| e >= current_epoch) {
709            return Ok(());
710        }
711
712        // Epochs strictly below this one are deleted; this one and later are preserved.
713        let retain_from_epoch = current_epoch - 1;
714        // Everything below the previous cutoff is already gone, so only cover the epochs
715        // that became eligible since then. A fresh process starts from epoch 0.
716        let from_epoch = last_pruned_epoch.map_or(0, |e| e - 1);
717        info!(
718            "Pruning executed_transaction_digests for epochs {}..{} (current epoch: {})",
719            from_epoch, retain_from_epoch, current_epoch
720        );
721
722        #[cfg(tidehunter)]
723        {
724            let from_key = (from_epoch, TransactionDigest::ZERO);
725            let to_key = (retain_from_epoch - 1, TransactionDigest::new([0xff; 32]));
726            perpetual_db
727                .executed_transaction_digests
728                .drop_cells_in_range(&from_key, &to_key)?;
729        }
730        #[cfg(not(tidehunter))]
731        {
732            let start_key = (from_epoch, TransactionDigest::ZERO);
733            // `to` is non-inclusive so retain_from_epoch and all later epochs are preserved
734            let end_key = (retain_from_epoch, TransactionDigest::ZERO);
735            let mut batch = perpetual_db.executed_transaction_digests.batch();
736            batch.schedule_delete_range(
737                &perpetual_db.executed_transaction_digests,
738                &start_key,
739                &end_key,
740            )?;
741            batch.write()?;
742        }
743
744        metrics
745            .last_pruned_executed_tx_digests_epoch
746            .set((retain_from_epoch - 1) as i64);
747        *last_pruned_epoch = Some(current_epoch);
748        Ok(())
749    }
750
751    fn update_pruning_watermarks(
752        perpetual_db: &Arc<AuthorityPerpetualTables>,
753        checkpoint_store: &Arc<CheckpointStore>,
754        num_epochs_to_retain: u64,
755        pruning_watermark: &Arc<PrunerWatermarks>,
756        objects_compactor_active: bool,
757    ) -> anyhow::Result<bool> {
758        use std::sync::atomic::Ordering;
759        let objects_pruning_checkpoint_id = perpetual_db
760            .get_highest_pruned_checkpoint()?
761            .unwrap_or_default();
762        let objects_pruning_epoch_id = checkpoint_store
763            .get_checkpoint_by_sequence_number(objects_pruning_checkpoint_id)?
764            .map(|chk| chk.epoch)
765            .unwrap_or_default();
766
767        let current_watermark = pruning_watermark.epoch_id.load(Ordering::Relaxed);
768        let current_epoch_id = checkpoint_store
769            .get_highest_executed_checkpoint()?
770            .map(|c| c.epoch)
771            .unwrap_or_default();
772        if current_epoch_id < num_epochs_to_retain {
773            return Ok(false);
774        }
775        let target_epoch_id = current_epoch_id - num_epochs_to_retain;
776        let checkpoint_id =
777            checkpoint_store.get_epoch_last_checkpoint_seq_number(target_epoch_id)?;
778
779        // The objects compactor handles object retention continuously without advancing
780        // `highest_pruned_checkpoint`, so capping on it would freeze the watermark at 0.
781        let new_watermark = if objects_compactor_active {
782            target_epoch_id + 1
783        } else {
784            min(target_epoch_id + 1, objects_pruning_epoch_id)
785        };
786        if current_watermark == new_watermark {
787            return Ok(false);
788        }
789        info!("relocation: setting epoch watermark to {}", new_watermark);
790        pruning_watermark
791            .epoch_id
792            .store(new_watermark, Ordering::Relaxed);
793        if let Some(checkpoint_id) = checkpoint_id {
794            let watermark = if objects_compactor_active {
795                checkpoint_id
796            } else {
797                min(checkpoint_id, objects_pruning_checkpoint_id)
798            };
799            info!("relocation: setting checkpoint watermark to {}", watermark);
800            pruning_watermark
801                .checkpoint_id
802                .store(watermark, Ordering::Relaxed);
803        }
804        Ok(true)
805    }
806
807    #[cfg(tidehunter)]
808    fn prune_th(
809        perpetual_db: &Arc<AuthorityPerpetualTables>,
810        checkpoint_store: &Arc<CheckpointStore>,
811        num_epochs_to_retain: u64,
812        pruning_watermark: Arc<PrunerWatermarks>,
813        objects_compactor_active: bool,
814    ) -> anyhow::Result<()> {
815        let watermark_updated = Self::update_pruning_watermarks(
816            perpetual_db,
817            checkpoint_store,
818            num_epochs_to_retain,
819            &pruning_watermark,
820            objects_compactor_active,
821        )?;
822        if !watermark_updated {
823            info!("skip relocation. Watermark hasn't changed");
824            return Ok(());
825        }
826        perpetual_db.objects.db.start_relocation()?;
827        checkpoint_store.tables.watermarks.db.start_relocation()?;
828        Ok(())
829    }
830
831    #[cfg(not(tidehunter))]
832    fn compact_next_sst_file(
833        perpetual_db: Arc<AuthorityPerpetualTables>,
834        delay_days: usize,
835        last_processed: Arc<Mutex<HashMap<String, SystemTime>>>,
836    ) -> anyhow::Result<Option<LiveFile>> {
837        let db_path = perpetual_db.objects.db.path_for_pruning();
838        let mut state = last_processed
839            .lock()
840            .expect("failed to obtain a lock for last processed SST files");
841        let mut sst_file_for_compaction: Option<LiveFile> = None;
842        let time_threshold =
843            SystemTime::now() - Duration::from_secs(delay_days as u64 * 24 * 60 * 60);
844        for sst_file in perpetual_db.objects.db.live_files()? {
845            let file_path = db_path.join(sst_file.name.clone().trim_matches('/'));
846            let last_modified = std::fs::metadata(file_path)?.modified()?;
847            if !PERIODIC_PRUNING_TABLES.contains(&sst_file.column_family_name)
848                || sst_file.level < 1
849                || sst_file.start_key.is_none()
850                || sst_file.end_key.is_none()
851                || last_modified > time_threshold
852                || state.get(&sst_file.name).unwrap_or(&UNIX_EPOCH) > &time_threshold
853            {
854                continue;
855            }
856            if let Some(candidate) = &sst_file_for_compaction
857                && candidate.size > sst_file.size
858            {
859                continue;
860            }
861            sst_file_for_compaction = Some(sst_file);
862        }
863        let Some(sst_file) = sst_file_for_compaction else {
864            return Ok(None);
865        };
866        info!(
867            "Manual compaction of sst file {:?}. Size: {:?}, level: {:?}",
868            sst_file.name, sst_file.size, sst_file.level
869        );
870        perpetual_db.objects.compact_range_raw(
871            &sst_file.column_family_name,
872            sst_file.start_key.clone().unwrap(),
873            sst_file.end_key.clone().unwrap(),
874        )?;
875        state.insert(sst_file.name.clone(), SystemTime::now());
876        Ok(Some(sst_file))
877    }
878
879    fn pruning_tick_duration_ms(epoch_duration_ms: u64) -> u64 {
880        min(epoch_duration_ms / 2, MIN_PRUNING_TICK_DURATION_MS)
881    }
882
883    fn smoothed_max_eligible_checkpoint_number(
884        checkpoint_store: &Arc<CheckpointStore>,
885        mut max_eligible_checkpoint: CheckpointSequenceNumber,
886        pruned_checkpoint: CheckpointSequenceNumber,
887        epoch_id: EpochId,
888        epoch_duration_ms: u64,
889        num_epochs_to_retain: u64,
890    ) -> anyhow::Result<CheckpointSequenceNumber> {
891        if epoch_id < num_epochs_to_retain {
892            return Ok(0);
893        }
894        let last_checkpoint_in_epoch = checkpoint_store
895            .get_epoch_last_checkpoint(epoch_id - num_epochs_to_retain)?
896            .map(|checkpoint| checkpoint.sequence_number)
897            .unwrap_or_default();
898        max_eligible_checkpoint = max_eligible_checkpoint.min(last_checkpoint_in_epoch);
899        if max_eligible_checkpoint == 0 {
900            return Ok(max_eligible_checkpoint);
901        }
902        let num_intervals = epoch_duration_ms
903            .checked_div(Self::pruning_tick_duration_ms(epoch_duration_ms))
904            .unwrap_or(1);
905        let delta = max_eligible_checkpoint
906            .saturating_sub(pruned_checkpoint)
907            .checked_div(num_intervals)
908            .unwrap_or(1);
909        Ok(pruned_checkpoint + delta)
910    }
911
912    fn setup_pruning(
913        config: AuthorityStorePruningConfig,
914        epoch_duration_ms: u64,
915        perpetual_db: Arc<AuthorityPerpetualTables>,
916        checkpoint_store: Arc<CheckpointStore>,
917        rpc_store: Option<RpcStore>,
918        metrics: Arc<AuthorityStorePruningMetrics>,
919        pruner_watermarks: Arc<PrunerWatermarks>,
920    ) -> Sender<()> {
921        let (sender, mut recv) = tokio::sync::oneshot::channel();
922        debug!(
923            "Starting object pruning service with num_epochs_to_retain={}",
924            config.num_epochs_to_retain
925        );
926
927        let tick_duration =
928            Duration::from_millis(Self::pruning_tick_duration_ms(epoch_duration_ms));
929        let pruning_initial_delay = if cfg!(msim) {
930            Duration::from_millis(1)
931        } else {
932            Duration::from_secs(config.pruning_run_delay_seconds.unwrap_or(60 * 60))
933        };
934
935        metrics
936            .num_epochs_to_retain_for_objects
937            .set(config.num_epochs_to_retain as i64);
938        metrics.num_epochs_to_retain_for_checkpoints.set(
939            config
940                .num_epochs_to_retain_for_checkpoints
941                .unwrap_or_default() as i64,
942        );
943
944        #[cfg(tidehunter)]
945        {
946            // Object and checkpoint pruning (relocation) are only active when checkpoint
947            // retention is configured. The executed-transaction-digest cleanup below runs
948            // regardless, so the loop is always spawned.
949            let checkpoint_retention = config.num_epochs_to_retain_for_checkpoints();
950            let prune_objects =
951                checkpoint_retention.is_some() && config.num_epochs_to_retain != u64::MAX;
952            let prune_loop = async move {
953                let mut retraction_cursors = RetractionCursors::default();
954                let mut last_pruned_tx_digests_epoch = None;
955                let mut objects_prune_interval =
956                    tokio::time::interval_at(Instant::now() + pruning_initial_delay, tick_duration);
957                let mut checkpoints_prune_interval =
958                    tokio::time::interval_at(Instant::now() + pruning_initial_delay, tick_duration);
959                // No initial delay: a node that accumulated many epochs of digests while
960                // this cleanup was disabled must reclaim them right after startup.
961                let mut tx_digests_prune_interval =
962                    tokio::time::interval_at(Instant::now(), tick_duration);
963                loop {
964                    tokio::select! {
965                        _ = objects_prune_interval.tick(), if prune_objects => {
966                            if let Err(err) = Self::prune_objects_for_eligible_epochs(&perpetual_db, &checkpoint_store, rpc_store.as_ref(), &mut retraction_cursors, config.clone(), metrics.clone(), epoch_duration_ms).await {
967                                error!("Failed to prune objects: {:?}", err);
968                            }
969                        },
970                        _ = checkpoints_prune_interval.tick(), if checkpoint_retention.is_some() => {
971                            let Some(num_epochs_to_retain) = checkpoint_retention else { continue };
972                            if let Err(err) = Self::prune_th(&perpetual_db, &checkpoint_store, num_epochs_to_retain, pruner_watermarks.clone(), !prune_objects) {
973                                error!("Failed to prune checkpoints: {:?}", err);
974                            }
975                        },
976                        _ = tx_digests_prune_interval.tick() => {
977                            if let Err(err) = Self::prune_executed_tx_digests(&perpetual_db, &checkpoint_store, &metrics, &mut last_pruned_tx_digests_epoch) {
978                                error!("Failed to prune executed_transaction_digests: {:?}", err);
979                            }
980                        },
981                        _ = &mut recv => break,
982                    }
983                }
984            };
985
986            #[cfg(not(msim))]
987            std::thread::Builder::new()
988                .name("authority-store-pruner".to_string())
989                .spawn(move || {
990                    let runtime = tokio::runtime::Builder::new_current_thread()
991                        .enable_all()
992                        .build()
993                        .expect("Failed to build pruner tokio runtime");
994                    runtime.block_on(prune_loop);
995                })
996                .expect("Failed to spawn authority store pruner thread");
997
998            #[cfg(msim)]
999            tokio::task::spawn(prune_loop);
1000        }
1001        #[cfg(not(tidehunter))]
1002        {
1003            let perpetual_db_for_compaction = perpetual_db.clone();
1004            if let Some(delay_days) = config.periodic_compaction_threshold_days {
1005                spawn_monitored_task!(async move {
1006                    let last_processed = Arc::new(Mutex::new(HashMap::new()));
1007                    loop {
1008                        let db = perpetual_db_for_compaction.clone();
1009                        let state = Arc::clone(&last_processed);
1010                        let result = tokio::task::spawn_blocking(move || {
1011                            Self::compact_next_sst_file(db, delay_days, state)
1012                        })
1013                        .await;
1014                        let mut sleep_interval_secs = 1;
1015                        match result {
1016                            Err(err) => error!("Failed to compact sst file: {:?}", err),
1017                            Ok(Err(err)) => error!("Failed to compact sst file: {:?}", err),
1018                            Ok(Ok(None)) => {
1019                                sleep_interval_secs = 3600;
1020                            }
1021                            _ => {}
1022                        }
1023                        tokio::time::sleep(Duration::from_secs(sleep_interval_secs)).await;
1024                    }
1025                });
1026            }
1027
1028            let prune_loop = async move {
1029                let mut retraction_cursors = RetractionCursors::default();
1030                let mut last_pruned_tx_digests_epoch = None;
1031                let mut objects_prune_interval =
1032                    tokio::time::interval_at(Instant::now() + pruning_initial_delay, tick_duration);
1033                let mut checkpoints_prune_interval =
1034                    tokio::time::interval_at(Instant::now() + pruning_initial_delay, tick_duration);
1035                // Not gated on any retention setting and without initial delay; see
1036                // `prune_executed_tx_digests`.
1037                let mut tx_digests_prune_interval =
1038                    tokio::time::interval_at(Instant::now(), tick_duration);
1039                loop {
1040                    tokio::select! {
1041                        _ = objects_prune_interval.tick(), if config.num_epochs_to_retain != u64::MAX => {
1042                            if let Err(err) = Self::prune_objects_for_eligible_epochs(&perpetual_db, &checkpoint_store, rpc_store.as_ref(), &mut retraction_cursors, config.clone(), metrics.clone(), epoch_duration_ms).await {
1043                                error!("Failed to prune objects: {:?}", err);
1044                            }
1045                        },
1046                        _ = tx_digests_prune_interval.tick() => {
1047                            if let Err(err) = Self::prune_executed_tx_digests(&perpetual_db, &checkpoint_store, &metrics, &mut last_pruned_tx_digests_epoch) {
1048                                error!("Failed to prune executed_transaction_digests: {:?}", err);
1049                            }
1050                        },
1051                        _ = checkpoints_prune_interval.tick(), if !matches!(config.num_epochs_to_retain_for_checkpoints(), None | Some(u64::MAX) | Some(0)) => {
1052                            if let Err(err) = Self::prune_checkpoints_for_eligible_epochs(&perpetual_db, &checkpoint_store, rpc_store.as_ref(), config.clone(), metrics.clone(), epoch_duration_ms, &pruner_watermarks).await {
1053                                error!("Failed to prune checkpoints: {:?}", err);
1054                            }
1055                        },
1056                        _ = &mut recv => break,
1057                    }
1058                }
1059            };
1060
1061            #[cfg(not(msim))]
1062            std::thread::Builder::new()
1063                .name("authority-store-pruner".to_string())
1064                .spawn(move || {
1065                    let runtime = tokio::runtime::Builder::new_current_thread()
1066                        .enable_all()
1067                        .build()
1068                        .expect("Failed to build pruner tokio runtime");
1069                    runtime.block_on(prune_loop);
1070                })
1071                .expect("Failed to spawn authority store pruner thread");
1072
1073            #[cfg(msim)]
1074            tokio::task::spawn(prune_loop);
1075        }
1076        sender
1077    }
1078
1079    pub fn new(
1080        perpetual_db: Arc<AuthorityPerpetualTables>,
1081        checkpoint_store: Arc<CheckpointStore>,
1082        rpc_store: Option<RpcStore>,
1083        mut pruning_config: AuthorityStorePruningConfig,
1084        is_validator: bool,
1085        epoch_duration_ms: u64,
1086        registry: &Registry,
1087        pruner_watermarks: Arc<PrunerWatermarks>, // used by tidehunter relocation filters
1088    ) -> Self {
1089        // On tidehunter, the per-keyspace `objects_compactor`
1090        // (see `AuthorityPerpetualTables::open`) already retains only the latest
1091        // version per ObjectID during compaction, so running the object pruner
1092        // would duplicate that work. The compactor is enabled for validators
1093        // and for any node configured with `num_epochs_to_retain = 0` — keep
1094        // this predicate in sync with the one in `sui-node/src/lib.rs`.
1095        // Force-disable the pruner whenever the compactor is active.
1096        #[cfg(tidehunter)]
1097        {
1098            let objects_compactor_enabled =
1099                is_validator || pruning_config.num_epochs_to_retain == 0;
1100            if objects_compactor_enabled && pruning_config.num_epochs_to_retain != u64::MAX {
1101                info!(
1102                    "Tidehunter: disabling object pruner (was num_epochs_to_retain={}). The objects compactor performs equivalent compaction.",
1103                    pruning_config.num_epochs_to_retain
1104                );
1105                pruning_config.num_epochs_to_retain = u64::MAX;
1106            }
1107        }
1108
1109        if pruning_config.num_epochs_to_retain > 0 && pruning_config.num_epochs_to_retain < u64::MAX
1110        {
1111            warn!(
1112                "Using objects pruner with num_epochs_to_retain = {} can lead to performance issues",
1113                pruning_config.num_epochs_to_retain
1114            );
1115            if is_validator {
1116                warn!("Resetting to aggressive pruner.");
1117                pruning_config.num_epochs_to_retain = 0;
1118            } else {
1119                warn!("Consider using an aggressive pruner (num_epochs_to_retain = 0)");
1120            }
1121        }
1122        AuthorityStorePruner {
1123            _objects_pruner_cancel_handle: Self::setup_pruning(
1124                pruning_config,
1125                epoch_duration_ms,
1126                perpetual_db,
1127                checkpoint_store,
1128                rpc_store,
1129                AuthorityStorePruningMetrics::new(registry),
1130                pruner_watermarks,
1131            ),
1132        }
1133    }
1134
1135    pub fn compact(perpetual_db: &Arc<AuthorityPerpetualTables>) -> Result<(), TypedStoreError> {
1136        perpetual_db.objects.compact_range(
1137            &ObjectKey(ObjectID::ZERO, SequenceNumber::MIN),
1138            &ObjectKey(ObjectID::MAX, SequenceNumber::MAX),
1139        )
1140    }
1141}
1142
1143#[cfg(tidehunter)]
1144pub(crate) fn apply_relocation_filter<T: DeserializeOwned>(
1145    config: typed_store::tidehunter_util::KeySpaceConfig,
1146    pruner_watermark: Arc<AtomicU64>,
1147    extractor: impl Fn(T) -> u64 + Send + Sync + 'static,
1148    by_key: bool,
1149) -> typed_store::tidehunter_util::KeySpaceConfig {
1150    use bincode::Options;
1151    use std::sync::atomic::Ordering;
1152    use typed_store::tidehunter_util::Decision;
1153    config.with_relocation_filter(move |key, value| {
1154        let data = if by_key {
1155            bincode::DefaultOptions::new()
1156                .with_big_endian()
1157                .with_fixint_encoding()
1158                .deserialize(key)
1159                .expect("relocation filter deserialization error")
1160        } else {
1161            bcs::from_bytes(value).expect("relocation filter deserialization error")
1162        };
1163        if extractor(data) < pruner_watermark.load(Ordering::Relaxed) {
1164            Decision::Remove
1165        } else {
1166            Decision::StopRelocation
1167        }
1168    })
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    use more_asserts as ma;
1174    #[cfg(not(tidehunter))]
1175    use std::collections::HashSet;
1176    use std::path::Path;
1177    use std::sync::Arc;
1178    #[cfg(not(tidehunter))]
1179    use std::time::Duration;
1180    use tracing::log::info;
1181
1182    use crate::authority::authority_store_pruner::AuthorityStorePruningMetrics;
1183    use crate::authority::authority_store_tables::AuthorityPerpetualTables;
1184    use crate::authority::authority_store_types::get_store_object;
1185    #[cfg(not(tidehunter))]
1186    use crate::authority::authority_store_types::{StoreObject, StoreObjectWrapper};
1187    use prometheus::Registry;
1188    use sui_types::base_types::ObjectDigest;
1189    use sui_types::effects::TransactionEffects;
1190    use sui_types::effects::TransactionEffectsAPI;
1191    use sui_types::{
1192        base_types::{ObjectID, SequenceNumber},
1193        object::Object,
1194        storage::ObjectKey,
1195    };
1196    use typed_store::Map;
1197    #[cfg(not(tidehunter))]
1198    use typed_store::rocks::{DBMap, MetricConf, ReadWriteOptions, default_db_options};
1199
1200    use super::AuthorityStorePruner;
1201    use sui_rpc_store::RetractionCursors;
1202    /// The embedded rpc-store gate: no bound without a store, nothing
1203    /// eligible while any cohort pipeline is unwatermarked, and one
1204    /// past the slowest pipeline's watermark otherwise.
1205    #[test]
1206    fn rpc_store_gate_bounds_eligible_checkpoints() {
1207        use sui_consistent_store::Db;
1208        use sui_consistent_store::DbOptions;
1209        use sui_consistent_store::FrameworkSchema;
1210        use sui_consistent_store::PipelineTaskKey;
1211        use sui_consistent_store::Watermark;
1212        use sui_rpc_store::HISTORY_COHORT;
1213        use sui_rpc_store::LIVE_COHORT;
1214        use sui_rpc_store::RpcStoreSchema;
1215
1216        // No embedded store configured: pruning is unbounded.
1217        assert_eq!(
1218            AuthorityStorePruner::rpc_store_max_eligible_checkpoint(None).unwrap(),
1219            u64::MAX,
1220        );
1221
1222        let dir = tempfile::tempdir().unwrap();
1223        let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
1224        let store = sui_rpc_store::Store::new(db.clone(), Arc::new(schema));
1225
1226        // Cohort pipelines with no watermarks yet (a from-genesis
1227        // build before its first commit): nothing is eligible.
1228        assert_eq!(
1229            AuthorityStorePruner::rpc_store_max_eligible_checkpoint(Some(&store)).unwrap(),
1230            0,
1231        );
1232
1233        // Every cohort pipeline committed through 41 except one
1234        // history pipeline lagging at 7: pruning may cover the
1235        // straggler's committed range [0, 7] and no further.
1236        let framework = FrameworkSchema::new(db.clone());
1237        let mut batch = db.batch();
1238        for name in LIVE_COHORT.iter().chain(HISTORY_COHORT) {
1239            batch
1240                .put(
1241                    &framework.watermarks,
1242                    &PipelineTaskKey::new(*name),
1243                    &Watermark::for_checkpoint(41),
1244                )
1245                .unwrap();
1246        }
1247        batch
1248            .put(
1249                &framework.watermarks,
1250                &PipelineTaskKey::new(HISTORY_COHORT[0]),
1251                &Watermark::for_checkpoint(7),
1252            )
1253            .unwrap();
1254        batch.commit().unwrap();
1255        assert_eq!(
1256            AuthorityStorePruner::rpc_store_max_eligible_checkpoint(Some(&store)).unwrap(),
1257            8,
1258        );
1259    }
1260
1261    #[cfg(not(tidehunter))]
1262    fn get_keys_after_pruning(path: &Path) -> anyhow::Result<HashSet<ObjectKey>> {
1263        let perpetual_db_path = path.join(Path::new("perpetual"));
1264        let cf_names = AuthorityPerpetualTables::describe_tables();
1265        let cfs: Vec<_> = cf_names
1266            .keys()
1267            .map(|x| (x.as_str(), default_db_options().options))
1268            .collect();
1269        let perpetual_db = typed_store::rocks::open_cf_opts(
1270            perpetual_db_path,
1271            None,
1272            MetricConf::new("perpetual_pruning"),
1273            &cfs,
1274        );
1275
1276        let mut after_pruning = HashSet::new();
1277        let objects = DBMap::<ObjectKey, StoreObjectWrapper>::reopen(
1278            &perpetual_db?,
1279            Some("objects"),
1280            // open the db to bypass default db options which ignores range tombstones
1281            // so we can read the accurate number of retained versions
1282            &ReadWriteOptions::default(),
1283            false,
1284        )?;
1285        let iter = objects.safe_iter();
1286        for item in iter {
1287            after_pruning.insert(item?.0);
1288        }
1289        Ok(after_pruning)
1290    }
1291
1292    #[cfg(not(tidehunter))]
1293    type GenerateTestDataResult = (Vec<ObjectKey>, Vec<ObjectKey>, Vec<ObjectKey>);
1294
1295    #[cfg(not(tidehunter))]
1296    fn generate_test_data(
1297        db: Arc<AuthorityPerpetualTables>,
1298        num_versions_per_object: u64,
1299        num_object_versions_to_retain: u64,
1300        total_unique_object_ids: u32,
1301    ) -> Result<GenerateTestDataResult, anyhow::Error> {
1302        assert!(num_versions_per_object >= num_object_versions_to_retain);
1303
1304        let (mut to_keep, mut to_delete, mut tombstones) = (vec![], vec![], vec![]);
1305        let mut batch = db.objects.batch();
1306
1307        let ids = ObjectID::in_range(ObjectID::ZERO, total_unique_object_ids.into())?;
1308        for id in ids {
1309            for (counter, seq) in (0..num_versions_per_object).rev().enumerate() {
1310                let object_key = ObjectKey(id, SequenceNumber::from_u64(seq));
1311                if counter < num_object_versions_to_retain.try_into().unwrap() {
1312                    // latest `num_object_versions_to_retain` should not have been pruned
1313                    to_keep.push(object_key);
1314                } else {
1315                    to_delete.push(object_key);
1316                }
1317                let obj = get_store_object(Object::immutable_with_id_for_testing(id));
1318                batch.insert_batch(
1319                    &db.objects,
1320                    [(ObjectKey(id, SequenceNumber::from(seq)), obj.clone())],
1321                )?;
1322            }
1323
1324            // Adding a tombstone for deleted object.
1325            if num_object_versions_to_retain == 0 {
1326                let tombstone_key = ObjectKey(id, SequenceNumber::from(num_versions_per_object));
1327                println!("Adding tombstone object {:?}", tombstone_key);
1328                batch.insert_batch(
1329                    &db.objects,
1330                    [(tombstone_key, StoreObjectWrapper::V1(StoreObject::Deleted))],
1331                )?;
1332                tombstones.push(tombstone_key);
1333            }
1334        }
1335        batch.write().unwrap();
1336        assert_eq!(
1337            to_keep.len() as u64,
1338            std::cmp::min(num_object_versions_to_retain, num_versions_per_object)
1339                * total_unique_object_ids as u64
1340        );
1341        assert_eq!(
1342            tombstones.len() as u64,
1343            if num_object_versions_to_retain == 0 {
1344                total_unique_object_ids as u64
1345            } else {
1346                0
1347            }
1348        );
1349        Ok((to_keep, to_delete, tombstones))
1350    }
1351
1352    #[cfg(not(tidehunter))]
1353    async fn run_pruner(
1354        path: &Path,
1355        num_versions_per_object: u64,
1356        num_object_versions_to_retain: u64,
1357        total_unique_object_ids: u32,
1358    ) -> Vec<ObjectKey> {
1359        let registry = Registry::default();
1360        let metrics = AuthorityStorePruningMetrics::new(&registry);
1361        let to_keep = {
1362            let db = Arc::new(AuthorityPerpetualTables::open(path, None, None));
1363            let (to_keep, to_delete, tombstones) = generate_test_data(
1364                db.clone(),
1365                num_versions_per_object,
1366                num_object_versions_to_retain,
1367                total_unique_object_ids,
1368            )
1369            .unwrap();
1370            let mut effects = TransactionEffects::default();
1371            for object in to_delete {
1372                effects.unsafe_add_deleted_live_object_for_testing((
1373                    object.0,
1374                    object.1,
1375                    ObjectDigest::MIN,
1376                ));
1377            }
1378            for object in tombstones {
1379                effects.unsafe_add_object_tombstone_for_testing((
1380                    object.0,
1381                    object.1,
1382                    ObjectDigest::MIN,
1383                ));
1384            }
1385            AuthorityStorePruner::prune_objects_and_indexes(
1386                vec![(0, effects)],
1387                &db,
1388                0,
1389                metrics,
1390                0,
1391                None,
1392                &mut RetractionCursors::default(),
1393                true,
1394            )
1395            .await
1396            .unwrap();
1397            to_keep
1398        };
1399        tokio::time::sleep(Duration::from_secs(3)).await;
1400        to_keep
1401    }
1402
1403    // Tests pruning old version of live objects.
1404    #[cfg(not(tidehunter))]
1405    #[tokio::test]
1406    async fn test_pruning_objects() {
1407        let path = tempfile::tempdir().unwrap().keep();
1408        let to_keep = run_pruner(&path, 3, 2, 1000).await;
1409        assert_eq!(
1410            HashSet::from_iter(to_keep),
1411            get_keys_after_pruning(&path).unwrap()
1412        );
1413        run_pruner(&tempfile::tempdir().unwrap().keep(), 3, 2, 1000).await;
1414    }
1415
1416    // Tests pruning deleted objects (object tombstones).
1417    #[cfg(not(tidehunter))]
1418    #[tokio::test]
1419    async fn test_pruning_tombstones() {
1420        let path = tempfile::tempdir().unwrap().keep();
1421        let to_keep = run_pruner(&path, 0, 0, 1000).await;
1422        assert_eq!(to_keep.len(), 0);
1423        assert_eq!(get_keys_after_pruning(&path).unwrap().len(), 0);
1424
1425        let path = tempfile::tempdir().unwrap().keep();
1426        let to_keep = run_pruner(&path, 3, 0, 1000).await;
1427        assert_eq!(to_keep.len(), 0);
1428        assert_eq!(get_keys_after_pruning(&path).unwrap().len(), 0);
1429    }
1430
1431    #[cfg(not(target_env = "msvc"))]
1432    #[tokio::test]
1433    async fn test_db_size_after_compaction() -> Result<(), anyhow::Error> {
1434        let primary_path = tempfile::tempdir()?.keep();
1435        let perpetual_db = Arc::new(AuthorityPerpetualTables::open(&primary_path, None, None));
1436        let total_unique_object_ids = 10_000;
1437        let num_versions_per_object = 10;
1438        let ids = ObjectID::in_range(ObjectID::ZERO, total_unique_object_ids)?;
1439        let mut to_delete = vec![];
1440        for id in ids {
1441            for i in (0..num_versions_per_object).rev() {
1442                if i < num_versions_per_object - 2 {
1443                    to_delete.push((id, SequenceNumber::from(i)));
1444                }
1445                let obj = get_store_object(Object::immutable_with_id_for_testing(id));
1446                perpetual_db
1447                    .objects
1448                    .insert(&ObjectKey(id, SequenceNumber::from(i)), &obj)?;
1449            }
1450        }
1451
1452        fn get_sst_size(path: &Path) -> u64 {
1453            let mut size = 0;
1454            for entry in std::fs::read_dir(path).unwrap() {
1455                let entry = entry.unwrap();
1456                let path = entry.path();
1457                if let Some(ext) = path.extension() {
1458                    if ext != "sst" {
1459                        continue;
1460                    }
1461                    size += std::fs::metadata(path).unwrap().len();
1462                }
1463            }
1464            size
1465        }
1466
1467        let db_path = primary_path.clone().join("perpetual");
1468        let start = ObjectKey(ObjectID::ZERO, SequenceNumber::MIN);
1469        let end = ObjectKey(ObjectID::MAX, SequenceNumber::MAX);
1470
1471        perpetual_db.objects.compact_range(&start, &end)?;
1472        let before_compaction_size = get_sst_size(&db_path);
1473
1474        let mut effects = TransactionEffects::default();
1475        for object in to_delete {
1476            effects.unsafe_add_deleted_live_object_for_testing((
1477                object.0,
1478                object.1,
1479                ObjectDigest::MIN,
1480            ));
1481        }
1482        let registry = Registry::default();
1483        let metrics = AuthorityStorePruningMetrics::new(&registry);
1484        AuthorityStorePruner::prune_objects_and_indexes(
1485            vec![(0, effects)],
1486            &perpetual_db,
1487            0,
1488            metrics,
1489            0,
1490            None,
1491            &mut RetractionCursors::default(),
1492            true,
1493        )
1494        .await
1495        .unwrap();
1496        perpetual_db.objects.compact_range(&start, &end)?;
1497        let after_compaction_size = get_sst_size(&db_path);
1498
1499        info!(
1500            "Before compaction disk size = {:?}, after compaction disk size = {:?}",
1501            before_compaction_size, after_compaction_size
1502        );
1503        ma::assert_le!(after_compaction_size, before_compaction_size);
1504        Ok(())
1505    }
1506}