diff --git a/crates/iota-config/data/fullnode-template-with-path.yaml b/crates/iota-config/data/fullnode-template-with-path.yaml index 10f078546d81..3cd13e7e9eac 100644 --- a/crates/iota-config/data/fullnode-template-with-path.yaml +++ b/crates/iota-config/data/fullnode-template-with-path.yaml @@ -19,7 +19,9 @@ migration-tx-data-path: "migration.blob" authority-store-pruning-config: num-latest-epoch-dbs-to-retain: 3 - num-epochs-to-retain: 1 + # Number of epochs of superseded object versions and pruned checkpoint + # data to keep readable; whole epoch buckets are dropped past this window. + historic-epochs-to-retain: 2 authority-key-pair: path: "authority.key" diff --git a/crates/iota-config/data/fullnode-template.yaml b/crates/iota-config/data/fullnode-template.yaml index 88b8114c4758..6031550120cc 100644 --- a/crates/iota-config/data/fullnode-template.yaml +++ b/crates/iota-config/data/fullnode-template.yaml @@ -19,4 +19,6 @@ migration-tx-data-path: "/opt/iota/config/migration.blob" authority-store-pruning-config: num-latest-epoch-dbs-to-retain: 3 - num-epochs-to-retain: 1 + # Number of epochs of superseded object versions and pruned checkpoint + # data to keep readable; whole epoch buckets are dropped past this window. + historic-epochs-to-retain: 2 diff --git a/crates/iota-config/src/node.rs b/crates/iota-config/src/node.rs index aee7e5d5025b..bdd58eb20d13 100644 --- a/crates/iota-config/src/node.rs +++ b/crates/iota-config/src/node.rs @@ -1030,12 +1030,6 @@ pub struct AuthorityStorePruningConfig { /// number of the latest epoch dbs to retain #[serde(default = "default_num_latest_epoch_dbs_to_retain")] pub num_latest_epoch_dbs_to_retain: usize, - /// number of epochs to keep the latest version of objects for. - /// Note that a zero value corresponds to an aggressive pruner. - /// This mode is experimental and needs to be used with caution. - /// Use `u64::MAX` to disable the pruner for the objects. - #[serde(default)] - pub num_epochs_to_retain: u64, /// enables periodic background compaction for old SST files whose last /// modified time is older than `periodic_compaction_threshold_days` /// days. That ensures that all sst files eventually go through the @@ -1049,15 +1043,21 @@ pub struct AuthorityStorePruningConfig { /// for #[serde(skip_serializing_if = "Option::is_none")] pub num_epochs_to_retain_for_checkpoints: Option, - /// Enables the compaction filter for pruning the objects table. - /// If disabled, a range deletion approach is used instead. - /// While it is generally safe to switch between the two modes, - /// switching from the compaction filter approach back to range deletion - /// may result in some old versions that will never be pruned. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub enable_compaction_filter: bool, #[serde(skip_serializing_if = "Option::is_none")] pub num_epochs_to_retain_for_indexes: Option, + /// Number of epochs of historic data to retain: superseded object + /// versions and pruned checkpoint-keyed history (transactions, effects, + /// events, checkpoint contents and summaries) are relocated into + /// per-epoch buckets, bucketed by the epoch in which they were + /// superseded, and whole buckets are dropped once they fall out of this + /// window. Historic data remains readable through exact-version gRPC + /// lookups and checkpoint reads until then. + #[serde(default = "default_historic_epochs_to_retain")] + pub historic_epochs_to_retain: u64, +} + +fn default_historic_epochs_to_retain() -> u64 { + 2 } fn default_num_latest_epoch_dbs_to_retain() -> usize { @@ -1072,20 +1072,15 @@ impl Default for AuthorityStorePruningConfig { fn default() -> Self { Self { num_latest_epoch_dbs_to_retain: default_num_latest_epoch_dbs_to_retain(), - num_epochs_to_retain: 0, periodic_compaction_threshold_days: None, num_epochs_to_retain_for_checkpoints: if cfg!(msim) { Some(2) } else { None }, - enable_compaction_filter: cfg!(test) || cfg!(msim), num_epochs_to_retain_for_indexes: None, + historic_epochs_to_retain: default_historic_epochs_to_retain(), } } } impl AuthorityStorePruningConfig { - pub fn set_num_epochs_to_retain(&mut self, num_epochs_to_retain: u64) { - self.num_epochs_to_retain = num_epochs_to_retain; - } - pub fn set_num_epochs_to_retain_for_checkpoints(&mut self, num_epochs_to_retain: Option) { self.num_epochs_to_retain_for_checkpoints = num_epochs_to_retain; } diff --git a/crates/iota-core/src/authority.rs b/crates/iota-core/src/authority.rs index 1018db1c53f2..72e89fb4cc00 100644 --- a/crates/iota-core/src/authority.rs +++ b/crates/iota-core/src/authority.rs @@ -157,8 +157,8 @@ use crate::{ authority_per_epoch_store_pruner::AuthorityPerEpochStorePruner, authority_store::{ExecutionLockReadGuard, ObjectLockStatus}, authority_store_pruner::{AuthorityStorePruner, EPOCH_DURATION_MS_FOR_TESTING}, - authority_store_tables::AuthorityPrunerTables, epoch_start_configuration::{EpochStartConfigTrait, EpochStartConfiguration}, + historic_store::HistoricStore, }, authority_client::NetworkAuthorityClient, checkpoint_progress_tracker::CheckpointProgressTracker, @@ -233,6 +233,7 @@ pub mod authority_store_pruner; pub mod authority_store_tables; pub mod authority_store_types; pub mod epoch_start_configuration; +pub mod historic_store; pub mod shared_object_congestion_tracker; pub mod shared_object_version_manager; pub mod suggested_gas_price_calculator; @@ -853,6 +854,11 @@ pub struct AuthorityState { pub indexes: Option>, pub grpc_indexes_store: Option>, + /// Superseded object versions relocated out of the live objects table. + /// Read exclusively by the gRPC exact-version object lookup; consensus + /// and execution paths must never consult it. + pub historic_store: Arc, + pub subscription_handler: Arc, pub checkpoint_store: Arc, @@ -867,7 +873,7 @@ pub struct AuthorityState { pub metrics: Arc, /// The store pruner. The checkpoint executor uses it to nudge the pruner - /// after each checkpoint and to be leashed if pruning falls behind. + /// after each checkpoint. pruner: AuthorityStorePruner, authority_per_epoch_pruner: AuthorityPerEpochStorePruner, checkpoint_progress_tracker: Option>, @@ -1720,11 +1726,39 @@ impl AuthorityState { // Allow testing what happens if we crash here. fail_point!("crash"); - let transaction_outputs = TransactionOutputs::build_transaction_outputs( + let mut transaction_outputs = TransactionOutputs::build_transaction_outputs( transaction.clone().into_unsigned(), effects.clone(), inner_temporary_store, ); + // Mutations of runtime-loaded objects (dynamic fields) are not + // transaction inputs, so their pre-images could not be captured from + // `input_objects`. Read them back through the cache — the transaction + // just read them, so they are memory-hot — to complete the capture; + // a version already relocated is absent from the live view and needs + // no move. + let captured: HashSet = transaction_outputs + .superseded + .iter() + .map(|(key, _)| *key) + .collect(); + let missing: Vec = effects + .modified_at_versions() + .into_iter() + .map(|(object_id, version)| ObjectKey(object_id, version)) + .filter(|key| !captured.contains(key)) + .collect(); + if !missing.is_empty() { + let objects = self + .get_object_cache_reader() + .try_multi_get_objects_by_key(&missing)?; + transaction_outputs.superseded.extend( + missing + .into_iter() + .zip(objects) + .filter_map(|(key, object)| object.map(|object| (key, object))), + ); + } self.get_cache_writer() .try_write_transaction_outputs(epoch_store.epoch(), transaction_outputs.into())?; @@ -3258,7 +3292,7 @@ impl AuthorityState { archive_readers: ArchiveReaderBalancer, validator_tx_finalizer: Option>>, chain_identifier: ChainIdentifier, - pruner_db: Option>, + historic_store: Arc, checkpoint_progress_tracker: Option>, policy_config: Option, firewall_config: Option, @@ -3291,11 +3325,10 @@ impl AuthorityState { grpc_indexes_store.clone(), indexes.clone(), config.authority_store_pruning_config.clone(), - epoch_store.committee().authority_exists(&name), epoch_store.epoch_start_state().epoch_duration_ms(), prometheus_registry, archive_readers, - pruner_db, + historic_store.clone(), checkpoint_progress_tracker.clone(), ); let input_loader = @@ -3325,6 +3358,7 @@ impl AuthorityState { execution_cache_trait_pointers, indexes, grpc_indexes_store, + historic_store, subscription_handler: Arc::new(SubscriptionHandler::new(prometheus_registry)), checkpoint_store, committee_store, @@ -3426,8 +3460,8 @@ impl AuthorityState { &self.database_for_testing().perpetual_tables, &self.checkpoint_store, self.grpc_indexes_store.as_deref(), - None, - config.authority_store_pruning_config, + &self.historic_store, + &config.authority_store_pruning_config, metrics, archive_readers, EPOCH_DURATION_MS_FOR_TESTING, @@ -3846,6 +3880,10 @@ impl AuthorityState { } } + // The historic epoch buckets are column families of the perpetual + // database, so the perpetual snapshot above already covers them + // consistently — no separate history snapshot is needed. + fs::rename(checkpoint_path_tmp, checkpoint_path) .map_err(|e| IotaError::FileIO(e.to_string()))?; Ok(()) @@ -4065,11 +4103,22 @@ impl AuthorityState { object_id: &ObjectId, version: SequenceNumber, ) -> IotaResult)>> { - let Some(object) = self + // Superseded versions leave the live table when their checkpoint + // commits, so exact-version reads fall back to the historic buckets. + // This is a read-API path only; consensus and execution never take + // it. + let object = match self .get_object_cache_reader() .try_get_object_by_key(object_id, version)? - else { - return Ok(None); + { + Some(object) => object, + None => match self + .historic_store + .get_object(&ObjectKey(*object_id, version))? + { + Some(object) => object, + None => return Ok(None), + }, }; let layout = self.get_object_layout(&object)?; @@ -4277,16 +4326,49 @@ impl AuthorityState { &self, effects: &TransactionEffects, ) -> anyhow::Result> { - iota_types::storage::get_transaction_input_objects(self.get_object_store(), effects) - .map_err(Into::into) + let input_object_keys: Vec = effects + .modified_at_versions() + .into_iter() + .map(|(object_id, version)| ObjectKey(object_id, version)) + .collect(); + self.multi_get_objects_with_historic_fallback(&input_object_keys, effects) } pub fn get_transaction_output_objects( &self, effects: &TransactionEffects, ) -> anyhow::Result> { - iota_types::storage::get_transaction_output_objects(self.get_object_store(), effects) - .map_err(Into::into) + let output_object_keys: Vec = effects + .all_changed_objects() + .into_iter() + .map(|(object_ref, _owner, _kind)| ObjectKey::from(object_ref)) + .collect(); + self.multi_get_objects_with_historic_fallback(&output_object_keys, effects) + } + + /// Exact-version reads for response assembly: live table first, historic + /// buckets second. A transaction's own commit relocates the versions it + /// superseded, so responses assembled after the commit must be able to + /// reach them. Read-API only; execution never takes this path. + fn multi_get_objects_with_historic_fallback( + &self, + keys: &[ObjectKey], + effects: &TransactionEffects, + ) -> anyhow::Result> { + self.get_object_store() + .multi_get_objects_by_key(keys) + .into_iter() + .zip(keys) + .map(|(maybe_object, key)| match maybe_object { + Some(object) => Ok(object), + None => self.historic_store.get_object(key)?.ok_or_else(|| { + anyhow::anyhow!( + "missing object key {key:?} from tx {}", + effects.transaction_digest() + ) + }), + }) + .collect() } fn get_indexes(&self) -> IotaResult> { @@ -4350,7 +4432,7 @@ impl AuthorityState { } /// The store pruner; the checkpoint executor uses it to nudge the pruner - /// after each checkpoint and to be leashed when pruning falls behind. + /// after each checkpoint. pub fn pruner(&self) -> &AuthorityStorePruner { &self.pruner } @@ -6071,8 +6153,17 @@ impl TransactionKeyValueStoreTrait for AuthorityState { object_id: ObjectId, version: VersionNumber, ) -> IotaResult> { - self.get_object_cache_reader() - .try_get_object_by_key(&object_id, version) + // Exact-version serving read: superseded versions live in the + // historic buckets after their checkpoint committed. + match self + .get_object_cache_reader() + .try_get_object_by_key(&object_id, version)? + { + Some(object) => Ok(Some(object)), + None => self + .historic_store + .get_object(&ObjectKey(object_id, version)), + } } #[instrument(skip_all)] @@ -6080,9 +6171,15 @@ impl TransactionKeyValueStoreTrait for AuthorityState { &self, object_keys: &[ObjectKey], ) -> IotaResult>> { - Ok(self + let mut objects = self .get_object_cache_reader() - .multi_get_objects_by_key(object_keys)) + .multi_get_objects_by_key(object_keys); + for (maybe_object, key) in objects.iter_mut().zip(object_keys) { + if maybe_object.is_none() { + *maybe_object = self.historic_store.get_object(key)?; + } + } + Ok(objects) } async fn multi_get_transactions_perpetual_checkpoints( diff --git a/crates/iota-core/src/authority/authority_store.rs b/crates/iota-core/src/authority/authority_store.rs index b4fad13f1f6e..d84416b7baaf 100644 --- a/crates/iota-core/src/authority/authority_store.rs +++ b/crates/iota-core/src/authority/authority_store.rs @@ -38,18 +38,17 @@ use typed_store::{ }; use super::{ - authority_store_tables::{AuthorityPerpetualTables, LiveObject}, + authority_store_tables::{AuthorityPerpetualTables, HistoricMigrationProgress, LiveObject}, *, }; use crate::{ authority::{ authority_per_epoch_store::{AuthorityPerEpochStore, LockDetails}, - authority_store_pruner::{ - AuthorityStorePruner, AuthorityStorePruningMetrics, EPOCH_DURATION_MS_FOR_TESTING, - }, + authority_store_pruner::{AuthorityStorePruner, AuthorityStorePruningMetrics}, authority_store_tables::TotalIotaSupplyCheck, authority_store_types::{StoreObject, StoreObjectWrapper, get_store_object}, epoch_start_configuration::{EpochFlag, EpochStartConfiguration}, + historic_store::{HistoricStore, HistoricStoreMetrics}, }, global_state_hasher::GlobalStateHashStore, grpc_indexes::GrpcIndexesStore, @@ -125,6 +124,11 @@ pub struct AuthorityStore { pub(crate) perpetual_tables: Arc, + /// Checkpoint commit relocates superseded object versions into the + /// historic epoch buckets (column families of the same database) in the + /// same atomic write batch that deletes them from the live table. + pub(crate) historic_store: Arc, + pub(crate) root_state_notify_read: NotifyRead, @@ -146,6 +150,7 @@ impl AuthorityStore { config: &NodeConfig, registry: &Registry, migration_tx_data: Option<&MigrationTxData>, + historic_store: Arc, ) -> IotaResult> { let enable_epoch_iota_conservation_check = config .expensive_safety_check_config @@ -185,6 +190,7 @@ impl AuthorityStore { enable_epoch_iota_conservation_check, registry, migration_tx_data, + historic_store, ) .await?; this.update_epoch_flags_metrics(&[], epoch_start_configuration.flags()); @@ -231,7 +237,22 @@ impl AuthorityStore { // TODO: Since we always start at genesis, the committee should be technically // the same as the genesis committee. assert_eq!(committee.epoch, 0); - Self::open_inner(genesis, perpetual_tables, true, &Registry::new(), None).await + let historic_store = Arc::new( + HistoricStore::new_shared( + perpetual_tables.database(), + HistoricStoreMetrics::new(&Registry::new()), + ) + .expect("opening the historic store on a test database should not fail"), + ); + Self::open_inner( + genesis, + perpetual_tables, + true, + &Registry::new(), + None, + historic_store, + ) + .await } async fn open_inner( @@ -240,10 +261,12 @@ impl AuthorityStore { enable_epoch_iota_conservation_check: bool, registry: &Registry, migration_tx_data: Option<&MigrationTxData>, + historic_store: Arc, ) -> IotaResult> { let store = Arc::new(Self { mutex_table: MutexTable::new(NUM_SHARDS), perpetual_tables, + historic_store, root_state_notify_read: NotifyRead::< EpochId, (CheckpointSequenceNumber, GlobalStateHash), @@ -256,6 +279,14 @@ impl AuthorityStore { .database_is_empty() .expect("database read should not fail at init.") { + // A database created by this version has no pre-existing history + // to migrate: commit-time relocation covers everything from + // genesis on. + store + .perpetual_tables + .set_historic_migration(HistoricMigrationProgress::Complete) + .expect("cannot initialize the migration marker"); + // Initialize with genesis data // First insert genesis objects store @@ -367,10 +398,12 @@ impl AuthorityStore { perpetual_tables: Arc, enable_epoch_iota_conservation_check: bool, registry: &Registry, + historic_store: Arc, ) -> IotaResult> { let store = Arc::new(Self { mutex_table: MutexTable::new(NUM_SHARDS), perpetual_tables, + historic_store, root_state_notify_read: NotifyRead::< EpochId, (CheckpointSequenceNumber, GlobalStateHash), @@ -829,6 +862,22 @@ impl AuthorityStore { written.extend(outputs.written.values().cloned()); } + // Column-family creation is not part of a write batch, so the + // checkpoint epoch's bucket must exist before relocation stages into + // the batch below. The next epoch's bucket is pre-created in the + // background: creating its column families takes tens of + // milliseconds and would otherwise land on the first commit of each + // epoch (the synchronous call here is then a no-op). + self.historic_store.prepare_bucket(epoch_id)?; + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let historic_store = self.historic_store.clone(); + handle.spawn_blocking(move || { + if let Err(err) = historic_store.prepare_bucket(epoch_id + 1) { + tracing::warn!("failed to pre-create historic bucket: {err:?}"); + } + }); + } + let mut write_batch = self.perpetual_tables.transactions.batch(); for outputs in tx_outputs { self.write_one_transaction_outputs( @@ -912,6 +961,26 @@ impl AuthorityStore { write_batch.insert_batch(&self.perpetual_tables.objects, new_objects)?; + // The versions this transaction superseded move into the checkpoint + // epoch's bucket within this same atomic batch, keeping the live + // table heads-only. The pre-images were carried from execution; + // anything not captured (or already relocated) is picked up by the + // pruner's backstop pass. Tombstones written above stay in the live + // table as lineage heads; their keys go to the bucket's expiry list + // instead. + let relocated: Vec<_> = tx_outputs + .superseded + .iter() + .map(|(key, object)| (*key, get_store_object(object.clone(), None))) + .collect(); + let tombstone_heads: Vec<_> = deleted.iter().chain(wrapped.iter()).copied().collect(); + self.historic_store + .stage_objects(write_batch, epoch_id, &relocated, &tombstone_heads)?; + write_batch.delete_batch( + &self.perpetual_tables.objects, + relocated.iter().map(|(key, _)| *key), + )?; + // Write events into the new table keyed off of transaction_digest if effects.events_digest().is_some() { write_batch.insert_batch( @@ -1656,18 +1725,14 @@ impl AuthorityStore { checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, ) { - let pruning_config = AuthorityStorePruningConfig { - num_epochs_to_retain: 0, - ..Default::default() - }; + let pruning_config = AuthorityStorePruningConfig::default(); let _ = AuthorityStorePruner::prune_objects_for_eligible_epochs( &self.perpetual_tables, checkpoint_store, grpc_indexes_store, - None, - pruning_config, + &self.historic_store, + &pruning_config, AuthorityStorePruningMetrics::new_for_test(), - EPOCH_DURATION_MS_FOR_TESTING, None, ) .await; diff --git a/crates/iota-core/src/authority/authority_store_pruner.rs b/crates/iota-core/src/authority/authority_store_pruner.rs index c766e8d7d5d2..d2c07b25e87c 100644 --- a/crates/iota-core/src/authority/authority_store_pruner.rs +++ b/crates/iota-core/src/authority/authority_store_pruner.rs @@ -3,20 +3,22 @@ // SPDX-License-Identifier: Apache-2.0 use std::{ - cmp::{max, min}, + cmp::min, collections::{BTreeSet, HashMap}, - sync::{Arc, Mutex, Weak}, + sync::{Arc, Mutex}, time::{Duration, SystemTime, UNIX_EPOCH}, }; use anyhow::anyhow; -use bincode::Options; use iota_archival::reader::ArchiveReaderBalancer; +use iota_common::debug_fatal; use iota_config::node::AuthorityStorePruningConfig; use iota_metrics::{monitored_scope, spawn_monitored_task}; use iota_sdk_types::ObjectId; use iota_types::{ - base_types::{SequenceNumber, VersionNumber}, + base_types::SequenceNumber, + committee::EpochId, + digests::TransactionDigest, effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt}, messages_checkpoint::{ CheckpointContents, CheckpointContentsExt, CheckpointDigest, CheckpointSequenceNumber, @@ -37,14 +39,14 @@ use tokio::{ time::Instant, }; use tracing::{debug, error, info, warn}; -use typed_store::{ - Map, TypedStoreError, - rocksdb::{LiveFile, compaction_filter::Decision}, -}; +use typed_store::{Map, TypedStoreError, rocks::DBBatch, rocksdb::LiveFile}; -use super::authority_store_tables::{AuthorityPerpetualTables, AuthorityPrunerTables}; +use super::authority_store_tables::{AuthorityPerpetualTables, HistoricMigrationProgress}; use crate::{ - authority::authority_store_types::{StoreObject, StoreObjectWrapper}, + authority::{ + authority_store_types::{StoreObject, StoreObjectWrapper}, + historic_store::HistoricStore, + }, checkpoint_progress_tracker::CheckpointProgressTracker, checkpoints::{CheckpointStore, CheckpointWatermark}, grpc_indexes::GrpcIndexesStore, @@ -75,20 +77,22 @@ const MAX_CHECKPOINTS_IN_BATCH: usize = 10; /// `WriteBatch`. Bounds batch memory only (see [`MAX_CHECKPOINTS_IN_BATCH`]). const MAX_TRANSACTIONS_IN_BATCH: usize = 1000; -/// Chain-time slack, in milliseconds, allowed on top of the retention window -/// before the checkpoint executor is throttled by the pruner's leash -/// (`AuthorityStorePruner::await_leash`). It -/// absorbs transient bursts of high-contention checkpoints so execution runs at -/// the average prune rate rather than the peak; under sustained overload the -/// retained span stabilizes at `window + PRUNING_LEASH_SLACK_MS`, which is -/// negligible next to a multi-epoch window. -const PRUNING_LEASH_SLACK_MS: u64 = 60 * 60 * 1000; +/// Chain-time backlog, in milliseconds, above which the pruner warns that it +/// has fallen behind execution. Pruning never blocks execution: a node with a +/// large backlog (after downtime, a retention change, or during catch-up sync, +/// where chain time advances much faster than wall clock) executes at full +/// speed while the database temporarily grows, and this threshold makes that +/// condition visible to operators. +const PRUNING_BACKLOG_WARN_THRESHOLD_MS: u64 = 60 * 60 * 1000; + +/// Minimum interval between backlog warnings, so a persistently lagging pruner +/// does not warn on every drain. +const PRUNING_BACKLOG_WARN_INTERVAL: Duration = Duration::from_secs(5 * 60); /// While catching up (see [`PRUNING_DEBOUNCE_MIN_LAG`]), after a nudge wakes /// the pruner it waits this long before draining so that more executed /// checkpoints accumulate and their object deletions coalesce into larger, -/// fewer batches — which measurably improves catch-up throughput. Negligible -/// against the leash slack, so it never risks throttling execution. +/// fewer batches — which measurably improves catch-up throughput. const PRUNING_NUDGE_DEBOUNCE: Duration = Duration::from_millis(1000); /// The debounce above is only applied while the node is catching up, i.e. when @@ -97,31 +101,29 @@ const PRUNING_NUDGE_DEBOUNCE: Duration = Duration::from_millis(1000); /// (per-checkpoint) and does not incur the debounce delay. const PRUNING_DEBOUNCE_MIN_LAG: u64 = 100; +/// Upper bound on live-table rows scanned per legacy-sweep slice. Bounds both +/// the memory of a slice's write batch and the time the pruner task spends +/// per drain on the one-time sweep, so pruning and bucket expiry stay +/// responsive while the sweep works through a large table. +const LEGACY_SWEEP_ROWS_PER_SLICE: usize = 100_000; + /// The `AuthorityStorePruner` manages the pruning process for object stores /// within the `AuthorityStore`. It includes a cancellation handle that can be /// used to stop the pruning task for objects. /// -/// It also owns the coordination channels between the checkpoint executor +/// It also owns the coordination channel between the checkpoint executor /// (producer of new state) and the pruner task (consumer of aged-out state): /// pruning is driven by execution progress rather than a timer — the executor /// nudges after each checkpoint is made available, and the pruner drains fully -/// to its chain-time retention cutoff on every nudge. To keep on-disk state -/// bounded without a per-run rate cap (which could silently let the database -/// grow under sustained load), the executor is *leashed*: it stops scheduling -/// checkpoints while the pruner has fallen more than `PRUNING_LEASH_SLACK_MS` -/// behind its retention target. +/// to its chain-time retention cutoff on every nudge. Pruning never blocks +/// execution; if it falls behind, the database grows temporarily and the lag +/// is surfaced via metrics and a warning (see +/// [`PRUNING_BACKLOG_WARN_THRESHOLD_MS`]). pub struct AuthorityStorePruner { _objects_pruner_cancel_handle: oneshot::Sender<()>, /// Executor -> pruner: latest executed checkpoint sequence number. Updating /// it both records progress and wakes the pruner task to drain. executed: watch::Sender, - /// Pruner -> executor: the executed-checkpoint timestamp the pruner has - /// caught up to (the `highest_executed` it observed on its last completed - /// drain). The leash throttles execution while it runs more than - /// `PRUNING_LEASH_SLACK_MS` of chain-time ahead of this, i.e. ahead of the - /// pruner's last completed drain. Initialized to `u64::MAX` so the executor - /// is never leashed before the pruner has published a real value. - frontier_ms: watch::Sender, } impl AuthorityStorePruner { @@ -130,26 +132,6 @@ impl AuthorityStorePruner { pub fn nudge(&self, executed_seq: CheckpointSequenceNumber) { self.executed.send_replace(executed_seq); } - - /// Called by the executor before scheduling a checkpoint, passing the - /// timestamp of the current highest-executed checkpoint. Returns once the - /// pruner has caught up to within `PRUNING_LEASH_SLACK_MS` of chain-time of - /// that executed watermark, throttling execution otherwise. - /// - /// The argument is the *executed* watermark, never the candidate - /// checkpoint's timestamp: the pruner's frontier only ever advances to - /// timestamps that have already executed, so gating on a not-yet-executed - /// candidate could deadlock across a large chain-time gap between - /// checkpoints. - pub async fn await_leash(&self, executed_timestamp_ms: CheckpointTimestamp) { - let mut rx = self.frontier_ms.subscribe(); - while executed_timestamp_ms.saturating_sub(*rx.borrow_and_update()) > PRUNING_LEASH_SLACK_MS - { - // `changed()` cannot error: the sender lives in `self`, which is - // borrowed for the duration of this call. - let _ = rx.changed().await; - } - } } /// The `AuthorityStorePruningMetrics` tracks various metrics related to the @@ -160,8 +142,13 @@ pub struct AuthorityStorePruningMetrics { pub num_pruned_tombstones: IntCounter, pub last_pruned_effects_checkpoint: IntGauge, pub last_pruned_indexes_transaction: IntGauge, - pub num_epochs_to_retain_for_objects: IntGauge, + pub historic_epochs_to_retain: IntGauge, pub num_epochs_to_retain_for_checkpoints: IntGauge, + pub last_pruned_checkpoint_timestamp_ms: IntGauge, + pub last_pruned_effects_checkpoint_timestamp_ms: IntGauge, + pub pruning_chain_time_lag_ms: IntGauge, + pub historic_migration_state: IntGauge, + pub historic_capture_miss_total: IntCounter, } impl AuthorityStorePruningMetrics { @@ -200,9 +187,9 @@ impl AuthorityStorePruningMetrics { registry ) .unwrap(), - num_epochs_to_retain_for_objects: register_int_gauge_with_registry!( - "num_epochs_to_retain_for_objects", - "Number of epochs to retain for objects", + historic_epochs_to_retain: register_int_gauge_with_registry!( + "historic_epochs_to_retain", + "Number of epochs of historic data to retain before dropping epoch buckets", registry ) .unwrap(), @@ -212,6 +199,39 @@ impl AuthorityStorePruningMetrics { registry ) .unwrap(), + last_pruned_checkpoint_timestamp_ms: register_int_gauge_with_registry!( + "last_pruned_checkpoint_timestamp_ms", + "Timestamp of the last checkpoint whose objects were pruned", + registry + ) + .unwrap(), + last_pruned_effects_checkpoint_timestamp_ms: register_int_gauge_with_registry!( + "last_pruned_effects_checkpoint_timestamp_ms", + "Timestamp of the last checkpoint whose checkpoint data was pruned", + registry + ) + .unwrap(), + pruning_chain_time_lag_ms: register_int_gauge_with_registry!( + "pruning_chain_time_lag_ms", + "Chain time between the executed watermark and the target of the pruner's \ + last completed drain; large values mean pruning has fallen behind execution", + registry + ) + .unwrap(), + historic_migration_state: register_int_gauge_with_registry!( + "historic_migration_state", + "Progress of the one-time historic migration: 0 = legacy sweep running, \ + 1 = sweep complete, 2 = migration complete", + registry + ) + .unwrap(), + historic_capture_miss_total: register_int_counter_with_registry!( + "historic_capture_miss_total", + "Superseded object versions the walker still found in the live table after \ + the migration completed, i.e. commit-time relocation capture misses", + registry + ) + .unwrap(), }; Arc::new(this) } @@ -230,20 +250,50 @@ pub enum PruningMode { Checkpoints, } +/// Relocation target for one epoch-homogeneous pruning batch: instead of +/// deleting superseded object versions, move them into the historic store +/// bucket of the epoch whose checkpoints superseded them. +struct HistoricRelocation<'a> { + store: &'a Arc, + supersession_epoch: EpochId, + /// The bucket is already past the retention horizon (possible only while + /// draining migration backlog): skip the copy and delete outright, which + /// is equivalent to relocating and immediately dropping the bucket. + expired: bool, +} + +impl HistoricRelocation<'_> { + fn new( + store: &Arc, + supersession_epoch: EpochId, + current_epoch: EpochId, + historic_epochs_to_retain: u64, + ) -> HistoricRelocation<'_> { + HistoricRelocation { + store, + supersession_epoch, + expired: current_epoch.saturating_sub(supersession_epoch) > historic_epochs_to_retain, + } + } +} + impl AuthorityStorePruner { - /// prunes old versions of objects based on transaction effects + /// Relocates old versions of objects into the historic store based on + /// transaction effects, and advances the objects pruning watermark. + /// Returns how many superseded versions were still present in the live + /// table (zero when commit-time relocation already moved everything). async fn prune_objects( transaction_effects: Vec, perpetual_db: &Arc, - pruner_db: Option<&Arc>, + relocation: &HistoricRelocation<'_>, checkpoint_number: CheckpointSequenceNumber, metrics: Arc, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { let _scope = monitored_scope("ObjectsLivePruner"); let mut wb = perpetual_db.objects.batch(); - let mut pruner_db_wb = pruner_db.map(|db| db.object_tombstones.batch()); - // Collect objects keys that need to be deleted from `transaction_effects`. + // Collect objects keys that need to be relocated from + // `transaction_effects`. let mut live_object_keys_to_prune = vec![]; let mut object_tombstones_to_prune = vec![]; for effects in &transaction_effects { @@ -264,64 +314,168 @@ impl AuthorityStorePruner { .num_pruned_tombstones .inc_by(object_tombstones_to_prune.len() as u64); - let mut updates: HashMap = HashMap::new(); - for ObjectKey(object_id, seq_number) in live_object_keys_to_prune { - updates - .entry(object_id) - .and_modify(|range| *range = (min(range.0, seq_number), max(range.1, seq_number))) - .or_insert((seq_number, seq_number)); + let found = Self::relocate_objects( + &mut wb, + perpetual_db, + relocation, + live_object_keys_to_prune, + object_tombstones_to_prune, + )?; + perpetual_db.set_highest_pruned_checkpoint(&mut wb, checkpoint_number)?; + metrics.last_pruned_checkpoint.set(checkpoint_number as i64); + wb.write()?; + Ok(found) + } + + /// Moves superseded object versions into the historic epoch bucket + /// instead of deleting them: the copies and the point deletes of the + /// relocated keys go into the same `wb`, so relocation commits + /// atomically with the watermark advance (the bucket's column families + /// belong to the same database). + /// + /// This is the backstop behind commit-time relocation: rows already + /// moved by checkpoint commit (or a previous run) come back absent from + /// the `multi_get` and are skipped, which keeps replay idempotent. It + /// does the full work only for backlog predating the feature. + /// + /// Tombstone heads (`Deleted`/`Wrapped`) are *not* relocated and *not* + /// deleted: they are the newest version of their lineage, and every + /// latest-version read depends on them staying in the live table. They + /// are recorded in the bucket's expiry list and point-deleted from the + /// live table only when the whole bucket expires. The versions *below* a + /// tombstone need no lineage scan here: each of them was relocated by the + /// effects of the transaction that superseded it (deletion consumes its + /// input version like any other mutation). + fn relocate_objects( + wb: &mut DBBatch, + perpetual_db: &Arc, + relocation: &HistoricRelocation<'_>, + live_object_keys_to_prune: Vec, + tombstone_heads: Vec, + ) -> anyhow::Result { + if relocation.expired { + // The bucket would be dropped by the next retention pass anyway: + // delete the superseded versions and the tombstone heads outright + // instead of copying them. Expired batches are migration backlog + // by construction (a freshly executed checkpoint is never past + // retention), so nothing is reported as found. + wb.delete_batch(&perpetual_db.objects, live_object_keys_to_prune)?; + wb.delete_batch(&perpetual_db.objects, tombstone_heads)?; + return Ok(0); } - for (object_id, (min_version, max_version)) in updates { - debug!( - "Pruning object {:?} versions {:?} - {:?}", - object_id, min_version, max_version - ); - match pruner_db_wb { - Some(ref mut batch) => { - batch.insert_batch( - &pruner_db.expect("invariant checked").object_tombstones, - std::iter::once((object_id, max_version)), - )?; - } - None => { - let start_range = ObjectKey(object_id, min_version); - let end_range = ObjectKey(object_id, max_version + 1); - wb.schedule_delete_range(&perpetual_db.objects, &start_range, &end_range)?; - } - } + let values = perpetual_db + .objects + .multi_get(live_object_keys_to_prune.iter())?; + let rows: Vec<_> = live_object_keys_to_prune + .into_iter() + .zip(values) + .filter_map(|(key, value)| value.map(|value| (key, value))) + .collect(); + for (key, _) in &rows { + debug!(?key, "walker found a superseded version in the live table"); } - // Instead of using range deletes, we - // need to do a scan of all the keys for the deleted objects and then do - // point deletes to delete all the existing keys. This is because using - // range delete to delete tombstones may leak objects (imagine a tombstone - // is compacted away, but earlier version is still not). Using point - // deletes guarantees that all earlier versions are deleted in the - // database. - if !object_tombstones_to_prune.is_empty() { - let mut object_keys_to_delete = vec![]; - for ObjectKey(object_id, seq_number) in object_tombstones_to_prune { - for result in perpetual_db.objects.safe_iter_with_bounds( - Some(ObjectKey(object_id, VersionNumber::MIN_VALID_INCL)), - Some(ObjectKey(object_id, seq_number.next().unwrap())), - ) { - let (object_key, _) = result?; - assert_eq!(object_key.0, object_id); - object_keys_to_delete.push(object_key); - } - } + relocation + .store + .prepare_bucket(relocation.supersession_epoch)?; + relocation.store.stage_objects( + wb, + relocation.supersession_epoch, + &rows, + &tombstone_heads, + )?; + wb.delete_batch(&perpetual_db.objects, rows.iter().map(|(key, _)| *key))?; + Ok(rows.len()) + } - wb.delete_batch(&perpetual_db.objects, object_keys_to_delete)?; + /// Stages one epoch-homogeneous batch of checkpoint-keyed history into + /// the historic bucket of the checkpoints' epoch, inside the same + /// perpetual-store batch that deletes the perpetual-side source rows. + /// The checkpoint store's deletes live in a separate database and are + /// committed after the perpetual batch; a crash in between leaves + /// harmless duplicates that the idempotent replay overwrites. + /// + /// Rows already deleted by a previous run are skipped, which keeps + /// replay idempotent: their historic copies were already written. + fn relocate_checkpoint_data( + perpetual_batch: &mut DBBatch, + perpetual_db: &Arc, + checkpoint_db: &Arc, + relocation: &HistoricRelocation<'_>, + transactions: &[TransactionDigest], + checkpoints_to_prune: &[CheckpointDigest], + checkpoint_content_to_prune: &[CheckpointContents], + effects_to_prune: &[TransactionEffects], + ) -> anyhow::Result<()> { + fn present(keys: &[K], values: Vec>) -> Vec<(K, V)> { + keys.iter() + .copied() + .zip(values) + .filter_map(|(key, value)| value.map(|value| (key, value))) + .collect() } - perpetual_db.set_highest_pruned_checkpoint(&mut wb, checkpoint_number)?; - metrics.last_pruned_checkpoint.set(checkpoint_number as i64); + let checkpoint_summaries = present( + checkpoints_to_prune, + checkpoint_db + .tables + .checkpoint_by_digest + .multi_get(checkpoints_to_prune.iter())?, + ); + // The contents-digest-to-sequence-number rows are derived from the + // summaries instead of read from + // `checkpoint_sequence_by_contents_digest`, whose rows are already + // deleted after state accumulation. + let checkpoint_seq_by_contents: Vec<_> = checkpoint_summaries + .iter() + .map(|(_, summary)| { + let summary = summary.inner(); + (summary.content_digest, summary.sequence_number) + }) + .collect(); + let checkpoint_range = checkpoint_summaries + .iter() + .map(|(_, summary)| summary.inner().sequence_number) + .fold(None, |range: Option<(u64, u64)>, seq| { + Some(range.map_or((seq, seq), |(min, max)| (min.min(seq), max.max(seq)))) + }); - if let Some(batch) = pruner_db_wb { - batch.write()?; - } - wb.write()?; + let data = crate::authority::historic_store::CheckpointHistoryBatch { + transactions: present( + transactions, + perpetual_db.transactions.multi_get(transactions.iter())?, + ), + effects: effects_to_prune + .iter() + .map(|effects| (effects.digest(), effects.clone())) + .collect(), + executed_effects: present( + transactions, + perpetual_db + .executed_effects + .multi_get(transactions.iter())?, + ), + events: present( + transactions, + perpetual_db.events_2.multi_get(transactions.iter())?, + ), + checkpoint_contents: checkpoint_content_to_prune + .iter() + .map(|contents| (contents.digest(), contents.clone())) + .collect(), + checkpoint_seq_by_contents, + checkpoints: checkpoint_summaries, + checkpoint_range, + }; + relocation + .store + .prepare_bucket(relocation.supersession_epoch)?; + relocation.store.stage_checkpoint_data( + perpetual_batch, + relocation.supersession_epoch, + data, + )?; Ok(()) } @@ -330,10 +484,20 @@ impl AuthorityStorePruner { /// based on the specified checkpoint number and list of checkpoints to /// prune. This function removes outdated data, updates pruning metrics, /// and maintains database consistency by updating watermarks. + /// + /// The checkpoint-keyed history (transactions, effects, events, + /// checkpoint contents and summaries) is durably copied into the historic + /// bucket of the checkpoints' epoch before the deletes are committed; on + /// a crash in between, replay finds the not-yet-deleted rows and rewrites + /// identical historic rows. Two families are deleted outright: the legacy + /// `events` table (a duplicate of `events_2` that is being migrated away) + /// and `executed_transactions_to_checkpoint` (only consumed by the + /// JSON-RPC read path, which does not serve historic data). fn prune_checkpoints( perpetual_db: &Arc, checkpoint_db: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, + relocation: &HistoricRelocation<'_>, checkpoint_number: CheckpointSequenceNumber, checkpoints_to_prune: Vec, checkpoint_content_to_prune: Vec, @@ -348,6 +512,19 @@ impl AuthorityStorePruner { .flat_map(|content| content.iter().map(|tx| tx.transaction)) .collect(); + if !relocation.expired { + Self::relocate_checkpoint_data( + &mut perpetual_batch, + perpetual_db, + checkpoint_db, + relocation, + &transactions, + &checkpoints_to_prune, + &checkpoint_content_to_prune, + effects_to_prune, + )?; + } + perpetual_batch.delete_batch(&perpetual_db.transactions, transactions.iter())?; perpetual_batch.delete_batch(&perpetual_db.executed_effects, transactions.iter())?; perpetual_batch.delete_batch( @@ -408,41 +585,50 @@ impl AuthorityStorePruner { Ok(()) } - /// Prunes old data based on effects from all checkpoints from epochs - /// eligible for pruning + /// Relocates superseded object versions for every checkpoint below the + /// executed watermark. + /// + /// Commit-time relocation already moves pre-images as checkpoints + /// commit, so for data written by this version the walk finds nothing to + /// do and only advances the watermark. Its real work is the backlog: + /// databases predating commit-time relocation (this is the migration + /// path) and any commit-time capture miss (this is the backstop). + /// Relocation is not deletion — walked data stays readable through the + /// historic store — so no retention window applies. pub async fn prune_objects_for_eligible_epochs( perpetual_db: &Arc, checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, - pruner_db: Option<&Arc>, - config: AuthorityStorePruningConfig, + historic_store: &Arc, + config: &AuthorityStorePruningConfig, metrics: Arc, - epoch_duration_ms: u64, progress_tracker: Option<&Arc>, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { let _scope = monitored_scope("PruneObjectsForEligibleEpochs"); let (max_eligible_checkpoint_number, cutoff_timestamp_ms) = checkpoint_store .get_highest_executed_checkpoint()? - .map(|c| { - let window_ms = config - .num_epochs_to_retain - .saturating_mul(epoch_duration_ms); - ( - c.sequence_number(), - c.timestamp_ms.saturating_sub(window_ms), - ) - }) + .map(|c| (c.sequence_number(), c.timestamp_ms)) .unwrap_or_default(); let pruned_checkpoint_number = perpetual_db .get_highest_pruned_checkpoint()? .unwrap_or_default(); + // The pruning mode that lags decides when an epoch bucket is complete + // and seals it: the checkpoint pruner's eligibility is capped at the + // objects watermark, so it always trails the objects pruner. Only + // when checkpoint pruning is disabled does the objects pruner seal. + let seals_buckets = matches!( + config.num_epochs_to_retain_for_checkpoints(), + None | Some(u64::MAX) | Some(0) + ); Self::prune_for_eligible_epochs( perpetual_db, checkpoint_store, grpc_indexes_store, - pruner_db, + historic_store, + config.historic_epochs_to_retain, + seals_buckets, PruningMode::Objects, - config.num_epochs_to_retain, + 0, pruned_checkpoint_number, max_eligible_checkpoint_number, cutoff_timestamp_ms, @@ -464,8 +650,8 @@ impl AuthorityStorePruner { perpetual_db: &Arc, checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, - pruner_db: Option<&Arc>, - config: AuthorityStorePruningConfig, + historic_store: &Arc, + config: &AuthorityStorePruningConfig, metrics: Arc, archive_readers: ArchiveReaderBalancer, epoch_duration_ms: u64, @@ -483,15 +669,15 @@ impl AuthorityStorePruner { .get_archive_watermark() .await? .unwrap_or(u64::MAX); - let mut max_eligible_checkpoint = min(latest_archived_checkpoint, last_executed_checkpoint); - if config.num_epochs_to_retain != u64::MAX { - max_eligible_checkpoint = min( - max_eligible_checkpoint, - perpetual_db - .get_highest_pruned_checkpoint()? - .unwrap_or_default(), - ); - } + // Capped at the objects watermark: the objects walker reads effects + // and checkpoint contents of the checkpoints it replays, so they must + // not be relocated out of the live tables before it has passed them. + let max_eligible_checkpoint = min( + min(latest_archived_checkpoint, last_executed_checkpoint), + perpetual_db + .get_highest_pruned_checkpoint()? + .unwrap_or_default(), + ); let num_epochs_to_retain = config .num_epochs_to_retain_for_checkpoints() .ok_or_else(|| anyhow!("config value not set"))?; @@ -502,7 +688,11 @@ impl AuthorityStorePruner { perpetual_db, checkpoint_store, grpc_indexes_store, - pruner_db, + historic_store, + config.historic_epochs_to_retain, + // The checkpoint pruner always seals: its eligibility is capped + // at the objects watermark, so it is the lagging pruning mode. + true, PruningMode::Checkpoints, num_epochs_to_retain, pruned_checkpoint_number, @@ -511,7 +701,8 @@ impl AuthorityStorePruner { metrics.clone(), progress_tracker, ) - .await + .await?; + Ok(()) } /// Prunes old object versions based on effects from all checkpoints from @@ -520,7 +711,9 @@ impl AuthorityStorePruner { perpetual_db: &Arc, checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, - pruner_db: Option<&Arc>, + historic_store: &Arc, + historic_epochs_to_retain: u64, + seals_buckets: bool, mode: PruningMode, num_epochs_to_retain: u64, starting_checkpoint_number: CheckpointSequenceNumber, @@ -528,10 +721,12 @@ impl AuthorityStorePruner { cutoff_timestamp_ms: CheckpointTimestamp, metrics: Arc, progress_tracker: Option<&Arc>, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { let _scope = monitored_scope("PruneForEligibleEpochs"); let mut checkpoint_number = starting_checkpoint_number; + let mut last_pruned_timestamp_ms = 0; + let mut total_found = 0; let current_epoch = checkpoint_store .get_highest_executed_checkpoint()? .map(|c| c.epoch()) @@ -540,6 +735,7 @@ impl AuthorityStorePruner { let mut checkpoints_to_prune = vec![]; let mut checkpoint_content_to_prune = vec![]; let mut effects_to_prune = vec![]; + let mut batch_epoch: Option = None; let mut pruning_start = Instant::now(); @@ -565,7 +761,71 @@ impl AuthorityStorePruner { { break; } + + // A batch must not span epochs: relocated rows are bucketed by + // the epoch of their checkpoint. Flush the pending batch before + // crossing the boundary and seal the finished epoch's bucket. + match batch_epoch { + Some(epoch) if epoch != checkpoint.epoch() => { + if !checkpoints_to_prune.is_empty() { + total_found += Self::prune_batch( + perpetual_db, + checkpoint_store, + grpc_indexes_store, + &HistoricRelocation::new( + historic_store, + epoch, + current_epoch, + historic_epochs_to_retain, + ), + mode, + checkpoint_number, + std::mem::take(&mut checkpoints_to_prune), + std::mem::take(&mut checkpoint_content_to_prune), + std::mem::take(&mut effects_to_prune), + metrics.clone(), + ) + .await?; + match mode { + PruningMode::Objects => &metrics.last_pruned_checkpoint_timestamp_ms, + PruningMode::Checkpoints => { + &metrics.last_pruned_effects_checkpoint_timestamp_ms + } + } + .set(last_pruned_timestamp_ms as i64); + if let Some(tracker) = progress_tracker { + let elapsed = pruning_start.elapsed(); + match mode { + PruningMode::Objects => tracker.add_object_pruning_time(elapsed), + PruningMode::Checkpoints => { + tracker.add_checkpoint_pruning_time(elapsed) + } + } + pruning_start = Instant::now(); + } + } + if seals_buckets { + historic_store.seal_epoch(epoch)?; + } + } + None if seals_buckets => { + // A previous run may have finished exactly at an + // epoch boundary without sealing; catch up on any + // unsealed earlier buckets. + for epoch in historic_store.list_epochs() { + if epoch >= checkpoint.epoch() { + break; + } + if !historic_store.is_sealed(epoch)? { + historic_store.seal_epoch(epoch)?; + } + } + } + _ => {} + } + batch_epoch = Some(checkpoint.epoch()); checkpoint_number = checkpoint.sequence_number(); + last_pruned_timestamp_ms = checkpoint.timestamp_ms; let content = checkpoint_store .get_checkpoint_contents(&checkpoint.content_digest)? @@ -587,28 +847,34 @@ impl AuthorityStorePruner { if effects_to_prune.len() >= MAX_TRANSACTIONS_IN_BATCH || checkpoints_to_prune.len() >= MAX_CHECKPOINTS_IN_BATCH { + total_found += Self::prune_batch( + perpetual_db, + checkpoint_store, + grpc_indexes_store, + &HistoricRelocation::new( + historic_store, + batch_epoch.expect("batch epoch is set before batching"), + current_epoch, + historic_epochs_to_retain, + ), + mode, + checkpoint_number, + std::mem::take(&mut checkpoints_to_prune), + std::mem::take(&mut checkpoint_content_to_prune), + std::mem::take(&mut effects_to_prune), + metrics.clone(), + ) + .await?; + + // Published per batch so dashboards show progress during long + // drains, not only at drain completion. match mode { - PruningMode::Objects => { - Self::prune_objects( - effects_to_prune, - perpetual_db, - pruner_db, - checkpoint_number, - metrics.clone(), - ) - .await? + PruningMode::Objects => &metrics.last_pruned_checkpoint_timestamp_ms, + PruningMode::Checkpoints => { + &metrics.last_pruned_effects_checkpoint_timestamp_ms } - PruningMode::Checkpoints => Self::prune_checkpoints( - perpetual_db, - checkpoint_store, - grpc_indexes_store, - checkpoint_number, - checkpoints_to_prune, - checkpoint_content_to_prune, - &effects_to_prune, - metrics.clone(), - )?, - }; + } + .set(last_pruned_timestamp_ms as i64); // Report pruning time for this batch so the progress logger // shows time alongside the checkpoint deltas it reads from the @@ -622,37 +888,36 @@ impl AuthorityStorePruner { pruning_start = Instant::now(); } - checkpoints_to_prune = vec![]; - checkpoint_content_to_prune = vec![]; - effects_to_prune = vec![]; // yield back to the tokio runtime. Prevent potential halt of other tasks tokio::task::yield_now().await; } } if !checkpoints_to_prune.is_empty() { + total_found += Self::prune_batch( + perpetual_db, + checkpoint_store, + grpc_indexes_store, + &HistoricRelocation::new( + historic_store, + batch_epoch.expect("batch epoch is set before batching"), + current_epoch, + historic_epochs_to_retain, + ), + mode, + checkpoint_number, + checkpoints_to_prune, + checkpoint_content_to_prune, + effects_to_prune, + metrics.clone(), + ) + .await?; + match mode { - PruningMode::Objects => { - Self::prune_objects( - effects_to_prune, - perpetual_db, - pruner_db, - checkpoint_number, - metrics.clone(), - ) - .await? - } - PruningMode::Checkpoints => Self::prune_checkpoints( - perpetual_db, - checkpoint_store, - grpc_indexes_store, - checkpoint_number, - checkpoints_to_prune, - checkpoint_content_to_prune, - &effects_to_prune, - metrics.clone(), - )?, - }; + PruningMode::Objects => &metrics.last_pruned_checkpoint_timestamp_ms, + PruningMode::Checkpoints => &metrics.last_pruned_effects_checkpoint_timestamp_ms, + } + .set(last_pruned_timestamp_ms as i64); // Report pruning time for this batch so the progress logger // shows time alongside the checkpoint deltas it reads from the @@ -666,9 +931,193 @@ impl AuthorityStorePruner { } } + Ok(total_found) + } + + /// Dispatches one pruning batch to the mode-specific pruner. In objects + /// mode, returns how many superseded versions were still present in the + /// live table. + async fn prune_batch( + perpetual_db: &Arc, + checkpoint_store: &Arc, + grpc_indexes_store: Option<&GrpcIndexesStore>, + relocation: &HistoricRelocation<'_>, + mode: PruningMode, + checkpoint_number: CheckpointSequenceNumber, + checkpoints_to_prune: Vec, + checkpoint_content_to_prune: Vec, + effects_to_prune: Vec, + metrics: Arc, + ) -> anyhow::Result { + match mode { + PruningMode::Objects => { + Self::prune_objects( + effects_to_prune, + perpetual_db, + relocation, + checkpoint_number, + metrics, + ) + .await + } + PruningMode::Checkpoints => { + Self::prune_checkpoints( + perpetual_db, + checkpoint_store, + grpc_indexes_store, + relocation, + checkpoint_number, + checkpoints_to_prune, + checkpoint_content_to_prune, + &effects_to_prune, + metrics, + )?; + Ok(0) + } + } + } + + /// Expires historic epoch buckets that have fallen out of retention: + /// point-deletes the bucket's tombstone heads from the live `objects` + /// table, then drops the whole bucket. + /// + /// The heads must be deleted first: a crash in between leaves the bucket + /// (and its expiry list) in place, so the next run re-issues the + /// idempotent deletes before dropping. The reversed order would lose the + /// expiry list and leak the heads in the live table forever. + fn drop_expired_historic_epochs( + perpetual_db: &Arc, + historic_store: &Arc, + current_epoch: EpochId, + num_epochs_to_retain: u64, + ) -> anyhow::Result<()> { + let _scope = monitored_scope("DropExpiredHistoricEpochs"); + for epoch in historic_store.list_epochs() { + if current_epoch.saturating_sub(epoch) <= num_epochs_to_retain { + break; + } + let tombstone_heads = historic_store.tombstone_heads(epoch)?; + let num_heads = tombstone_heads.len(); + for chunk in tombstone_heads.chunks(10_000) { + let mut wb = perpetual_db.objects.batch(); + wb.delete_batch(&perpetual_db.objects, chunk.iter().copied())?; + wb.write()?; + } + historic_store.drop_epoch(epoch)?; + info!( + epoch, + num_tombstone_heads = num_heads, + "dropped expired historic epoch bucket" + ); + } Ok(()) } + /// Runs one bounded slice of the one-time sweep that relocates + /// pre-existing superseded object versions out of the live table. + /// + /// This exists for databases whose checkpoint data below the objects + /// watermark was already pruned before the upgrade: the checkpoint walker + /// can never reach those rows (their effects are gone), so without the + /// sweep the migration could never finish. Rows below each lineage head + /// move into the historic bucket of `sweep_epoch` — their true + /// supersession epochs are unknowable, and a current-epoch bucket only + /// errs toward retaining them longer. Tombstone heads stay in the live + /// table but are recorded in the bucket's expiry list, exactly as in + /// checkpoint-driven relocation. The resume cursor is persisted in the + /// same atomic batch as the moves. + /// + /// Safe next to concurrent commits: the iterator reads a snapshot, cuts + /// only at lineage boundaries, and both sides' writes are idempotent + /// (a version is only ever relocated with identical bytes, and deletes + /// of already-relocated keys are no-ops). + fn legacy_sweep_slice( + perpetual_db: &Arc, + historic_store: &Arc, + sweep_epoch: EpochId, + resume_after: Option, + row_budget: usize, + ) -> anyhow::Result { + let _scope = monitored_scope("HistoricLegacySweep"); + + fn is_tombstone(wrapper: &StoreObjectWrapper) -> bool { + matches!( + wrapper.clone().migrate().into_inner(), + StoreObject::Deleted | StoreObject::Wrapped + ) + } + + // Strictly after every version of the cursor object: no version key + // reaches `MAX_VALID_EXCL`. + let lower_bound = resume_after.map(|id| ObjectKey(id, SequenceNumber::MAX_VALID_EXCL)); + + let mut relocated: Vec<(ObjectKey, StoreObjectWrapper)> = vec![]; + let mut tombstone_heads: Vec = vec![]; + let mut last_completed = resume_after; + // The row most recently read but not yet classified: it is the head + // of its lineage unless the next row shares its object id. + let mut pending: Option<(ObjectKey, StoreObjectWrapper)> = None; + let mut finished = true; + + for (scanned, row) in perpetual_db + .objects + .safe_iter_with_bounds(lower_bound, None) + .enumerate() + { + let (key, value) = row?; + if let Some((prev_key, prev_value)) = pending.take() { + if prev_key.0 == key.0 { + relocated.push((prev_key, prev_value)); + } else { + if is_tombstone(&prev_value) { + tombstone_heads.push(prev_key); + } + last_completed = Some(prev_key.0); + // Cut only at lineage boundaries so the cursor always + // names a fully processed object. + if scanned >= row_budget { + finished = false; + break; + } + } + } + pending = Some((key, value)); + } + if let Some((head_key, head_value)) = pending { + if finished { + if is_tombstone(&head_value) { + tombstone_heads.push(head_key); + } + last_completed = Some(head_key.0); + } + } + + let progress = if finished { + HistoricMigrationProgress::SweepComplete + } else { + HistoricMigrationProgress::Sweeping { + resume_after: last_completed, + } + }; + + historic_store.prepare_bucket(sweep_epoch)?; + let mut wb = perpetual_db.objects.batch(); + historic_store.stage_objects(&mut wb, sweep_epoch, &relocated, &tombstone_heads)?; + wb.delete_batch(&perpetual_db.objects, relocated.iter().map(|(key, _)| *key))?; + wb.insert_batch(&perpetual_db.historic_migration, [((), progress)])?; + wb.write()?; + + if !relocated.is_empty() || !tombstone_heads.is_empty() { + debug!( + relocated = relocated.len(), + tombstone_heads = tombstone_heads.len(), + sweep_epoch, + "legacy sweep slice relocated pre-existing versions" + ); + } + Ok(progress) + } + fn prune_indexes( indexes: Option<&IndexStore>, config: &AuthorityStorePruningConfig, @@ -755,19 +1204,20 @@ impl AuthorityStorePruner { checkpoint_store: Arc, grpc_indexes_store: Option>, jsonrpc_index: Option>, - pruner_db: Option>, + historic_store: Arc, metrics: Arc, archive_readers: ArchiveReaderBalancer, progress_tracker: Option>, mut executed_rx: watch::Receiver, - frontier_tx: watch::Sender, ) -> Sender<()> { let (sender, mut recv) = tokio::sync::oneshot::channel(); debug!( - "Starting store pruner with num_epochs_to_retain={}", - config.num_epochs_to_retain + "Starting store pruner with historic_epochs_to_retain={}", + config.historic_epochs_to_retain ); + let historic_epochs_to_retain = config.historic_epochs_to_retain; + // Periodic background compaction of aged SST files, independent of the // execution-driven pruning loop below. let perpetual_db_for_compaction = perpetual_db.clone(); @@ -796,39 +1246,52 @@ impl AuthorityStorePruner { } metrics - .num_epochs_to_retain_for_objects - .set(config.num_epochs_to_retain as i64); + .historic_epochs_to_retain + .set(historic_epochs_to_retain as i64); metrics.num_epochs_to_retain_for_checkpoints.set( config .num_epochs_to_retain_for_checkpoints .unwrap_or_default() as i64, ); - let prune_objects = config.num_epochs_to_retain != u64::MAX; let prune_checkpoints = !matches!( config.num_epochs_to_retain_for_checkpoints(), None | Some(u64::MAX) | Some(0) ); let prune_indexes = config.num_epochs_to_retain_for_indexes.is_some(); - // The leash only makes sense when something is actually being pruned; if - // no pruner is enabled the frontier stays at u64::MAX and execution is - // never throttled. - let leash_enabled = prune_objects || prune_checkpoints; // Execution-driven pruning: on every nudge from the checkpoint executor, - // drain each enabled pruner fully to its chain-time cutoff, then publish - // the pruning frontier for the executor's leash. Draining once before the - // first nudge handles any startup backlog. The `watch` nudge coalesces - // many executed checkpoints into a single drain. + // drain each enabled pruner fully to its chain-time cutoff. Draining + // once before the first nudge handles any startup backlog. The `watch` + // nudge coalesces many executed checkpoints into a single drain. + // Pruning never blocks execution: a backlog only grows the database + // temporarily, surfaced through the lag metric and warning below. tokio::task::spawn(async move { + // The target of the last completed drain: the executed-checkpoint + // timestamp observed when that drain started. Comparing it against + // the current executed watermark measures how far pruning has + // fallen behind execution in chain time — bounded and independent + // of epoch-duration variance. Initialized to `u64::MAX` so no lag + // is reported before the first drain completes. + let mut last_drain_target_ms: CheckpointTimestamp = u64::MAX; + let mut last_backlog_warn: Option = None; + let mut migration = match perpetual_db.get_historic_migration() { + Ok(progress) => progress, + Err(err) => { + error!("Failed to read the historic migration marker: {err:?}"); + HistoricMigrationProgress::Sweeping { resume_after: None } + } + }; + if migration != HistoricMigrationProgress::Complete { + info!(?migration, "historic migration in progress"); + } loop { - // The executed position this pass prunes up to. Published as the - // frontier once draining completes, so the leash measures how far - // execution has run ahead of the pruner's last completed drain — - // bounded and independent of epoch-duration variance, and free of - // the deadlock a `pruned + window` frontier could hit when the - // epoch guard or a mismatched `epoch_duration_ms` keeps that value - // permanently below `executed - slack`. + metrics.historic_migration_state.set(match migration { + HistoricMigrationProgress::Sweeping { .. } => 0, + HistoricMigrationProgress::SweepComplete => 1, + HistoricMigrationProgress::Complete => 2, + }); + // The executed position this pass prunes up to. let highest_executed = checkpoint_store .get_highest_executed_checkpoint() .ok() @@ -838,6 +1301,20 @@ impl AuthorityStorePruner { .map(|checkpoint| checkpoint.timestamp_ms) .unwrap_or(u64::MAX); + let lag_ms = caught_up_to.saturating_sub(last_drain_target_ms); + metrics.pruning_chain_time_lag_ms.set(lag_ms as i64); + if lag_ms > PRUNING_BACKLOG_WARN_THRESHOLD_MS + && last_backlog_warn + .is_none_or(|at| at.elapsed() >= PRUNING_BACKLOG_WARN_INTERVAL) + { + warn!( + lag_ms, + "pruning has fallen behind execution; the database grows until \ + pruning catches up" + ); + last_backlog_warn = Some(Instant::now()); + } + // Only batch (debounce) while catching up: if execution lags the // highest synced checkpoint by more than the threshold there is a // backlog to coalesce; near the tip the lag is tiny and we prune @@ -854,29 +1331,106 @@ impl AuthorityStorePruner { let catching_up = synced_seq.saturating_sub(executed_seq) > PRUNING_DEBOUNCE_MIN_LAG; - if prune_objects { - if let Err(err) = Self::prune_objects_for_eligible_epochs( - &perpetual_db, - &checkpoint_store, - grpc_indexes_store.as_deref(), - pruner_db.as_ref(), - config.clone(), - metrics.clone(), - epoch_duration_ms, - progress_tracker.as_ref(), - ) - .await - { + let objects_found = match Self::prune_objects_for_eligible_epochs( + &perpetual_db, + &checkpoint_store, + grpc_indexes_store.as_deref(), + &historic_store, + &config, + metrics.clone(), + progress_tracker.as_ref(), + ) + .await + { + Ok(found) => Some(found), + Err(err) => { error!("Failed to prune objects: {:?}", err); + None + } + }; + + match migration { + HistoricMigrationProgress::Sweeping { resume_after } => { + let sweep_epoch = highest_executed + .as_ref() + .map(|checkpoint| checkpoint.epoch()) + .unwrap_or_default(); + match Self::legacy_sweep_slice( + &perpetual_db, + &historic_store, + sweep_epoch, + resume_after, + LEGACY_SWEEP_ROWS_PER_SLICE, + ) { + Ok(progress) => { + if progress == HistoricMigrationProgress::SweepComplete { + info!("historic migration: legacy sweep complete"); + } + migration = progress; + } + Err(err) => error!("Legacy sweep slice failed: {err:?}"), + } + } + HistoricMigrationProgress::SweepComplete => { + // Complete once a drain reached the executed watermark + // having found nothing left to relocate: from then on, + // anything the walker finds is a commit-time capture + // miss. + let caught_up = perpetual_db + .get_highest_pruned_checkpoint() + .ok() + .flatten() + .unwrap_or_default() + + 1 + >= executed_seq; + if objects_found == Some(0) && caught_up { + match perpetual_db + .set_historic_migration(HistoricMigrationProgress::Complete) + { + Ok(()) => { + info!("historic migration complete"); + migration = HistoricMigrationProgress::Complete; + } + Err(err) => { + error!("Failed to persist the migration marker: {err:?}") + } + } + } + } + HistoricMigrationProgress::Complete => { + if let Some(found) = objects_found { + if found > 0 { + metrics.historic_capture_miss_total.inc_by(found as u64); + debug_fatal!( + "commit-time relocation missed {found} superseded object \ + versions; the walker relocated them" + ); + } + } } } + // Expire historic epoch buckets in the same drain: a cheap + // no-op while nothing has aged out of retention, and + // execution-driven like the pruning steps above. + let current_epoch = highest_executed + .as_ref() + .map(|checkpoint| checkpoint.epoch()) + .unwrap_or_default(); + if let Err(err) = Self::drop_expired_historic_epochs( + &perpetual_db, + &historic_store, + current_epoch, + historic_epochs_to_retain, + ) { + error!("Failed to drop expired historic epochs: {:?}", err); + } if prune_checkpoints { if let Err(err) = Self::prune_checkpoints_for_eligible_epochs( &perpetual_db, &checkpoint_store, grpc_indexes_store.as_deref(), - pruner_db.as_ref(), - config.clone(), + &historic_store, + &config, metrics.clone(), archive_readers.clone(), epoch_duration_ms, @@ -898,15 +1452,18 @@ impl AuthorityStorePruner { } } - if leash_enabled { - frontier_tx.send_replace(caught_up_to); - } + last_drain_target_ms = caught_up_to; + metrics.pruning_chain_time_lag_ms.set(0); tokio::select! { _ = &mut recv => break, // `changed()` cannot error: the paired sender lives in the // `AuthorityStorePruner` returned to the caller. _ = executed_rx.changed() => {} + // While the legacy sweep still has work, keep slicing even + // without execution progress. + _ = tokio::time::sleep(PRUNING_NUDGE_DEBOUNCE), + if matches!(migration, HistoricMigrationProgress::Sweeping { .. }) => {} } // Debounce only while catching up: let more executed checkpoints @@ -928,33 +1485,21 @@ impl AuthorityStorePruner { checkpoint_store: Arc, grpc_indexes_store: Option>, jsonrpc_index: Option>, - mut pruning_config: AuthorityStorePruningConfig, - is_validator: bool, + pruning_config: AuthorityStorePruningConfig, epoch_duration_ms: u64, registry: &Registry, archive_readers: ArchiveReaderBalancer, - pruner_db: Option>, + historic_store: Arc, progress_tracker: Option>, ) -> Self { - if pruning_config.num_epochs_to_retain > 0 && pruning_config.num_epochs_to_retain < u64::MAX - { - warn!( - "Using objects pruner with num_epochs_to_retain = {} can lead to performance issues", - pruning_config.num_epochs_to_retain - ); - if is_validator { - warn!("Resetting to aggressive pruner."); - pruning_config.num_epochs_to_retain = 0; - } else { - warn!("Consider using an aggressive pruner (num_epochs_to_retain = 0)"); - } + if let Err(err) = Self::fast_forward_objects_watermark(&perpetual_db, &checkpoint_store) { + error!("Failed to fast-forward the objects pruning watermark: {err:?}"); } - // Coordination channels between the checkpoint executor and the pruner - // task. The pruner task receives nudges (`executed_rx`) and publishes the - // frontier (`frontier_tx`); the executor-facing ends are kept on the - // returned handle for `nudge` / `await_leash`. + + // Coordination channel between the checkpoint executor and the pruner + // task. The pruner task receives nudges (`executed_rx`); the sending + // end is kept on the returned handle for `nudge`. let (executed, executed_rx) = watch::channel(0); - let (frontier_ms, _) = watch::channel(u64::MAX); AuthorityStorePruner { _objects_pruner_cancel_handle: Self::setup_pruning( pruning_config, @@ -963,95 +1508,58 @@ impl AuthorityStorePruner { checkpoint_store, grpc_indexes_store, jsonrpc_index, - pruner_db, + historic_store, AuthorityStorePruningMetrics::new(registry), archive_readers, progress_tracker, executed_rx, - frontier_ms.clone(), ), executed, - frontier_ms, } } - /// Compacts the entire range of objects stored in the `AuthorityStore` by - /// invoking a range compaction on the database. - pub fn compact(perpetual_db: &Arc) -> Result<(), TypedStoreError> { - perpetual_db.objects.compact_range( - &ObjectKey(ObjectId::ZERO, SequenceNumber::MIN_VALID_INCL), - &ObjectKey(ObjectId::MAX, SequenceNumber::MAX_VALID_EXCL), - ) - } -} - -#[derive(Clone)] -pub struct ObjectsCompactionFilter { - db: Weak, - metrics: Arc, -} - -impl ObjectsCompactionFilter { - pub fn new(db: Arc, registry: &Registry) -> Self { - Self { - db: Arc::downgrade(&db), - metrics: ObjectCompactionMetrics::new(registry), - } - } - pub fn filter(&mut self, key: &[u8], value: &[u8]) -> anyhow::Result { - let ObjectKey(object_id, version) = bincode::DefaultOptions::new() - .with_big_endian() - .with_fixint_encoding() - .deserialize(key)?; - let object: StoreObjectWrapper = bcs::from_bytes(value)?; - // Compaction sees raw on-disk rows, which may be legacy V1; migrate - // before `into_inner()`, which panics on an un-migrated V1. - if matches!(object.migrate().into_inner(), StoreObject::Value(_)) { - if let Some(db) = self.db.upgrade() { - match db.object_tombstones.get(&object_id)? { - Some(gc_version) => { - if version <= gc_version { - self.metrics.key_removed.inc(); - return Ok(Decision::Remove); - } - self.metrics.key_kept.inc(); - } - None => self.metrics.key_not_found.inc(), - } - } + /// Fast-forwards the objects pruning watermark past already-pruned + /// checkpoint data. + /// + /// A node that previously ran with objects pruning disabled + /// (`num_epochs_to_retain = u64::MAX`) but checkpoint pruning enabled has + /// checkpoint contents missing below the checkpoint watermark. Relocation + /// replays checkpoints from the objects watermark and would stall on the + /// missing data, so the watermark starts at the checkpoint watermark + /// instead. Superseded object versions from before that point stay in the + /// live table; they are non-heads that every read path already skips. + fn fast_forward_objects_watermark( + perpetual_db: &Arc, + checkpoint_store: &Arc, + ) -> anyhow::Result<()> { + let objects_watermark = perpetual_db + .get_highest_pruned_checkpoint()? + .unwrap_or_default(); + let checkpoints_watermark = checkpoint_store + .get_highest_pruned_checkpoint_seq_number()? + .unwrap_or_default(); + if objects_watermark < checkpoints_watermark { + warn!( + objects_watermark, + checkpoints_watermark, + "Fast-forwarding the objects pruning watermark: checkpoint data below the \ + checkpoint pruning watermark is already gone. Object versions superseded before \ + that point remain in the live objects table and will never be relocated." + ); + let mut wb = perpetual_db.pruned_checkpoint.batch(); + perpetual_db.set_highest_pruned_checkpoint(&mut wb, checkpoints_watermark)?; + wb.write()?; } - Ok(Decision::Keep) + Ok(()) } -} - -struct ObjectCompactionMetrics { - key_removed: IntCounter, - key_kept: IntCounter, - key_not_found: IntCounter, -} -impl ObjectCompactionMetrics { - pub fn new(registry: &Registry) -> Arc { - Arc::new(Self { - key_removed: register_int_counter_with_registry!( - "objects_compaction_filter_key_removed", - "Compaction key removed", - registry - ) - .unwrap(), - key_kept: register_int_counter_with_registry!( - "objects_compaction_filter_key_kept", - "Compaction key kept", - registry - ) - .unwrap(), - key_not_found: register_int_counter_with_registry!( - "objects_compaction_filter_key_not_found", - "Compaction key not found", - registry - ) - .unwrap(), - }) + /// Compacts the entire range of objects stored in the `AuthorityStore` by + /// invoking a range compaction on the database. + pub fn compact(perpetual_db: &Arc) -> Result<(), TypedStoreError> { + perpetual_db.objects.compact_range( + &ObjectKey(ObjectId::ZERO, SequenceNumber::MIN_VALID_INCL), + &ObjectKey(ObjectId::MAX, SequenceNumber::MAX_VALID_EXCL), + ) } } @@ -1065,7 +1573,8 @@ mod tests { base_types::{ObjectDigest, SequenceNumber}, digests::TransactionDigest, effects::{ - TransactionEffects, TransactionEffectsAPIForTesting, TransactionEffectsExtForTesting, + TransactionEffects, TransactionEffectsAPIForTesting, TransactionEffectsExt, + TransactionEffectsExtForTesting, }, messages_checkpoint::{CheckpointSequenceNumber, CheckpointTimestamp}, object::Object, @@ -1080,12 +1589,13 @@ mod tests { rocks::{DBMap, MetricConf, ReadWriteOptions, default_db_options}, }; - use super::{AuthorityStorePruner, PRUNING_LEASH_SLACK_MS, PruningMode}; + use super::{AuthorityStorePruner, HistoricRelocation, PruningMode}; use crate::{ authority::{ authority_store_pruner::AuthorityStorePruningMetrics, - authority_store_tables::AuthorityPerpetualTables, + authority_store_tables::{AuthorityPerpetualTables, HistoricMigrationProgress}, authority_store_types::{StoreObject, StoreObjectWrapper, get_store_object}, + historic_store::{HistoricStore, HistoricStoreMetrics}, }, checkpoints::CheckpointStore, }; @@ -1212,15 +1722,609 @@ mod tests { ObjectDigest::MIN, )); } - AuthorityStorePruner::prune_objects(vec![effects], &db, None, 0, metrics) - .await - .unwrap(); + let historic = open_historic(&db); + AuthorityStorePruner::prune_objects( + vec![effects], + &db, + &HistoricRelocation { + store: &historic, + supersession_epoch: 0, + expired: false, + }, + 0, + metrics, + ) + .await + .unwrap(); to_keep }; tokio::time::sleep(Duration::from_secs(3)).await; to_keep } + fn open_historic(db: &Arc) -> Arc { + Arc::new( + HistoricStore::new_shared(db.database(), HistoricStoreMetrics::new_for_test()).unwrap(), + ) + } + + /// Builds effects with production shapes: superseded input versions land + /// in `modified_at_versions()`, while `all_tombstones()` reports tombstone + /// refs at the effects' lamport version — strictly above every input + /// version, like the real tombstone rows written at the deleting + /// transaction's lamport version. + fn effects_superseding( + to_delete: &[ObjectKey], + tombstones: &[ObjectKey], + ) -> TransactionEffects { + use std::collections::BTreeMap; + + use iota_sdk_types::{ExecutionStatus, GasCostSummary}; + + let lamport_version = tombstones.iter().map(|key| key.1).max().unwrap_or_default(); + let mut effects = TransactionEffects::new_from_execution_v1( + ExecutionStatus::Success, + 0, + GasCostSummary::default(), + vec![], + std::collections::BTreeSet::new(), + TransactionDigest::default(), + lamport_version, + BTreeMap::new(), + None, + None, + vec![], + ); + for object in to_delete { + effects.unsafe_add_deleted_live_object_for_testing(ObjectReference::new( + object.0, + object.1, + ObjectDigest::MIN, + )); + } + for object in tombstones { + // The tombstone's input state is the last superseded version. + let input_version = SequenceNumber::from_u64(object.1.as_u64() - 1); + effects.unsafe_add_object_tombstone_for_testing(ObjectReference::new( + object.0, + input_version, + ObjectDigest::MIN, + )); + } + effects + } + + async fn relocate( + db: &Arc, + historic: &Arc, + supersession_epoch: u64, + effects: TransactionEffects, + checkpoint_number: u64, + ) { + AuthorityStorePruner::prune_objects( + vec![effects], + db, + &HistoricRelocation { + store: historic, + supersession_epoch, + expired: false, + }, + checkpoint_number, + AuthorityStorePruningMetrics::new_for_test(), + ) + .await + .unwrap(); + } + + fn live_keys(db: &Arc) -> HashSet { + db.objects.safe_iter().map(|item| item.unwrap().0).collect() + } + + /// After relocation the live table contains exactly the heads, and the + /// historic store contains exactly the superseded versions, bucketed by + /// the supersession epoch. + #[tokio::test] + async fn commit_time_relocation_moves_pre_images_in_the_commit_batch() { + use iota_sdk_types::{Address, Owner}; + use iota_types::{ + effects::TransactionEffectsExtForTesting, transaction::VerifiedTransaction, + }; + + use crate::{ + authority::authority_store::AuthorityStore, transaction_outputs::TransactionOutputs, + }; + + let tmp_dir = iota_common::tempdir(); + let perpetual_db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&perpetual_db); + let store = AuthorityStore::open_no_genesis( + perpetual_db.clone(), + false, + &Registry::default(), + historic.clone(), + ) + .unwrap(); + + // A live version v1 that the committed transaction supersedes with v2, + // its pre-image carried in the outputs. + let owner = Owner::Address(Address::ZERO); + let object_id = ObjectId::random(); + let object_v1 = Object::with_id_owner_version_for_testing( + object_id, + SequenceNumber::from_u64(1), + owner, + ); + let object_v2 = Object::with_id_owner_version_for_testing( + object_id, + SequenceNumber::from_u64(2), + owner, + ); + let key_v1 = ObjectKey(object_id, object_v1.version()); + let key_v2 = ObjectKey(object_id, object_v2.version()); + perpetual_db + .objects + .insert(&key_v1, &get_store_object(object_v1.clone(), None)) + .unwrap(); + + let transaction = VerifiedTransaction::new_genesis_transaction(vec![], vec![]); + let effects = TransactionEffects::new_empty_v1_for_testing(*transaction.digest()); + let outputs = TransactionOutputs { + transaction: Arc::new(transaction), + effects, + events: Default::default(), + markers: Default::default(), + wrapped: Default::default(), + deleted: Default::default(), + live_object_markers_to_delete: Default::default(), + new_live_object_markers_to_init: Default::default(), + written: [(object_id, object_v2)].into_iter().collect(), + superseded: vec![(key_v1, object_v1)], + }; + + // One atomic batch: v2 written, v1 relocated and deleted from live. + let batch = store.build_db_batch(3, 7, &[Arc::new(outputs)]).unwrap(); + batch.write().unwrap(); + + assert!(perpetual_db.objects.get(&key_v2).unwrap().is_some()); + assert!(perpetual_db.objects.get(&key_v1).unwrap().is_none()); + assert!(historic.get_store_object(&key_v1).unwrap().is_some()); + // The commit pre-creates the next epoch's bucket alongside its own, + // on a background thread — wait for it briefly. + for _ in 0..100 { + if historic.list_epochs() == vec![3, 4] { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(historic.list_epochs(), vec![3, 4]); + assert!( + historic + .get_object(&key_v1) + .unwrap() + .is_some_and(|object| object.version() == key_v1.1) + ); + } + + #[tokio::test] + async fn relocation_moves_superseded_versions_and_keeps_heads() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&db); + let (to_keep, to_delete, _) = generate_test_data(db.clone(), 3, 1, 100).unwrap(); + + relocate(&db, &historic, 5, effects_superseding(&to_delete, &[]), 1).await; + + assert_eq!(live_keys(&db), HashSet::from_iter(to_keep)); + for key in &to_delete { + assert!( + historic.get_store_object(key).unwrap().is_some(), + "{key:?} was not relocated" + ); + } + // Superseded in epoch 5, so the version created in epoch 1 lands in + // bucket 5; dropping older buckets must not affect it. + assert_eq!(historic.list_epochs(), vec![5]); + historic.drop_epoch(1).unwrap(); + assert!(historic.get_store_object(&to_delete[0]).unwrap().is_some()); + assert_eq!(db.get_highest_pruned_checkpoint().unwrap(), Some(1)); + } + + /// Deleted lineages keep their tombstone as the live head; latest-version + /// reads must still resolve to the tombstone after relocation. + #[tokio::test] + async fn relocation_keeps_tombstones_as_live_heads() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&db); + let (_, to_delete, tombstones) = generate_test_data(db.clone(), 3, 0, 10).unwrap(); + + relocate( + &db, + &historic, + 2, + effects_superseding(&to_delete, &tombstones), + 1, + ) + .await; + + assert_eq!(live_keys(&db), HashSet::from_iter(tombstones.clone())); + for tombstone in &tombstones { + let latest = db + .get_latest_object_ref_or_tombstone(tombstone.0) + .unwrap() + .expect("tombstone head must stay readable"); + assert_eq!( + latest, + ObjectReference::new(tombstone.0, tombstone.1, ObjectDigest::OBJECT_DELETED) + ); + } + assert_eq!( + HashSet::::from_iter(historic.tombstone_heads(2).unwrap()), + HashSet::from_iter(tombstones) + ); + for key in &to_delete { + assert!(historic.get_store_object(key).unwrap().is_some()); + } + } + + /// Replaying relocation after a crash between the historic write and the + /// live delete converges to the same state; replaying after completion is + /// a no-op. + #[tokio::test] + async fn relocation_replay_is_idempotent() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&db); + let (to_keep, to_delete, _) = generate_test_data(db.clone(), 3, 1, 50).unwrap(); + + // Simulate the crash window: the historic write landed, the live + // delete and watermark did not. + let rows: Vec<_> = to_delete + .iter() + .map(|key| (*key, db.objects.get(key).unwrap().unwrap())) + .collect(); + historic.put_objects(4, &rows, &[]).unwrap(); + + relocate(&db, &historic, 4, effects_superseding(&to_delete, &[]), 1).await; + let live_after_first = live_keys(&db); + assert_eq!(live_after_first, HashSet::from_iter(to_keep)); + + // Replay with the same effects: every key is already gone from the + // live table, so nothing changes. + relocate(&db, &historic, 4, effects_superseding(&to_delete, &[]), 1).await; + assert_eq!(live_keys(&db), live_after_first); + for key in &to_delete { + assert!(historic.get_store_object(key).unwrap().is_some()); + } + } + + /// Expiring a bucket deletes its tombstone heads from the live table; a + /// lineage resurrected at a higher version survives the exact-key delete + /// of its stale tombstone. + #[tokio::test] + async fn historic_expiry_deletes_tombstone_heads_and_spares_resurrections() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&db); + let (_, to_delete, tombstones) = generate_test_data(db.clone(), 3, 0, 10).unwrap(); + + relocate( + &db, + &historic, + 2, + effects_superseding(&to_delete, &tombstones), + 1, + ) + .await; + + // One lineage gets resurrected (e.g. unwrapped) at a higher version + // after its tombstone. + let resurrected_id = tombstones[0].0; + let resurrected_key = ObjectKey(resurrected_id, SequenceNumber::from_u64(7)); + db.objects + .insert( + &resurrected_key, + &get_store_object(Object::immutable_with_id_for_testing(resurrected_id), None), + ) + .unwrap(); + + // Bucket 2 is out of retention at epoch 100. + AuthorityStorePruner::drop_expired_historic_epochs(&db, &historic, 100, 10).unwrap(); + + assert_eq!(historic.list_epochs(), Vec::::new()); + assert_eq!(live_keys(&db), HashSet::from_iter([resurrected_key])); + // Buckets within retention stay. + let recent_key = ObjectKey(ObjectId::random(), SequenceNumber::from_u64(1)); + historic + .put_objects( + 95, + &[( + recent_key, + get_store_object(Object::immutable_with_id_for_testing(recent_key.0), None), + )], + &[], + ) + .unwrap(); + AuthorityStorePruner::drop_expired_historic_epochs(&db, &historic, 100, 10).unwrap(); + assert_eq!(historic.list_epochs(), vec![95]); + } + + /// Legacy V1 rows relocate as raw bytes and migrate at read time. + #[tokio::test] + async fn relocation_handles_legacy_v1_rows() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&db); + + let object = Object::immutable_with_id_for_testing(ObjectId::random()); + let key = ObjectKey(object.id(), object.version()); + db.insert_store_object_v1_test_only(object.clone()).unwrap(); + + relocate(&db, &historic, 3, effects_superseding(&[key], &[]), 1).await; + + assert!(db.objects.get(&key).unwrap().is_none()); + let relocated = historic + .get_object(&key) + .unwrap() + .expect("V1 row must be readable from the historic store"); + assert_eq!(relocated.id(), object.id()); + assert_eq!(relocated.version(), object.version()); + } + + /// The live object set observed by state hashing and snapshots is + /// unchanged by relocation and by bucket expiry. + #[tokio::test] + async fn live_object_set_is_invariant_under_relocation_and_expiry() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&db); + let (_, to_delete, _) = generate_test_data(db.clone(), 4, 1, 100).unwrap(); + + let live_set_before: Vec<_> = db + .iter_live_object_set() + .map(|live| live.object_reference()) + .collect(); + + relocate(&db, &historic, 3, effects_superseding(&to_delete, &[]), 1).await; + let live_set_after: Vec<_> = db + .iter_live_object_set() + .map(|live| live.object_reference()) + .collect(); + assert_eq!(live_set_before, live_set_after); + + AuthorityStorePruner::drop_expired_historic_epochs(&db, &historic, 100, 10).unwrap(); + let live_set_after_expiry: Vec<_> = db + .iter_live_object_set() + .map(|live| live.object_reference()) + .collect(); + assert_eq!(live_set_before, live_set_after_expiry); + } + + /// Checkpoint pruning with relocation moves the checkpoint-keyed history + /// (transactions, effects, events, checkpoint contents and summaries) + /// into the epoch bucket before deleting it, records the availability + /// watermark, and replays idempotently. + #[tokio::test] + async fn checkpoint_relocation_moves_history_and_replays_idempotently() { + use fastcrypto::traits::KeyPair; + use iota_protocol_config::ProtocolConfig; + use iota_sdk_types::GasCostSummary; + use iota_types::{ + base_types::ExecutionDigests, + committee::Committee, + effects::TransactionEvents, + messages_checkpoint::{ + CertifiedCheckpointSummary, CheckpointContents, CheckpointContentsExt, + CheckpointSummary, CheckpointSummaryExt, SignedCheckpointSummary, + VerifiedCheckpoint, + }, + transaction::VerifiedTransaction, + }; + + use crate::checkpoints::CheckpointStore; + + let tmp_dir = iota_common::tempdir(); + let perpetual_db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let checkpoint_db = CheckpointStore::new(&tmp_dir.path().join("checkpoints")); + let historic = open_historic(&perpetual_db); + + // One transaction with effects and events, wired into one checkpoint. + let transaction = VerifiedTransaction::new_genesis_transaction(vec![], vec![]); + let tx_digest = *transaction.digest(); + let effects = TransactionEffects::new_empty_v1_for_testing(tx_digest); + let fx_digest = effects.digest(); + let contents = + CheckpointContents::new_with_digests_only_for_tests([ExecutionDigests::new( + tx_digest, fx_digest, + )]); + + let (committee, keys) = Committee::new_simple_test_committee(); + let summary = CheckpointSummary::new_with_protocol_config( + &ProtocolConfig::get_for_max_version_UNSAFE(), + committee.epoch, + 9, + 1, + &contents, + None, + GasCostSummary::default(), + None, + 100, + Vec::new(), + ); + let signatures = keys + .iter() + .map(|key| { + SignedCheckpointSummary::new( + committee.epoch, + summary.clone(), + key, + key.public().into(), + ) + .auth_sig() + .clone() + }) + .collect(); + let cert = CertifiedCheckpointSummary::new(summary, signatures, &committee).unwrap(); + let verified_checkpoint = VerifiedCheckpoint::new_unchecked(cert); + let ckpt_digest = *verified_checkpoint.digest(); + let trusted_checkpoint = verified_checkpoint.serializable(); + + perpetual_db + .transactions + .insert(&tx_digest, transaction.serializable_ref()) + .unwrap(); + perpetual_db.effects.insert(&fx_digest, &effects).unwrap(); + perpetual_db + .executed_effects + .insert(&tx_digest, &fx_digest) + .unwrap(); + perpetual_db + .events_2 + .insert(&tx_digest, &TransactionEvents(vec![])) + .unwrap(); + checkpoint_db + .tables + .checkpoint_content + .insert(&contents.digest(), &contents) + .unwrap(); + checkpoint_db + .tables + .checkpoint_by_digest + .insert(&ckpt_digest, &trusted_checkpoint) + .unwrap(); + + let run = || { + AuthorityStorePruner::prune_checkpoints( + &perpetual_db, + &checkpoint_db, + None, + &HistoricRelocation { + store: &historic, + supersession_epoch: committee.epoch, + expired: false, + }, + 9, + vec![ckpt_digest], + vec![contents.clone()], + &vec![effects.clone()], + AuthorityStorePruningMetrics::new_for_test(), + ) + .unwrap() + }; + run(); + + // Source rows are gone. + assert!(perpetual_db.transactions.get(&tx_digest).unwrap().is_none()); + assert!(perpetual_db.effects.get(&fx_digest).unwrap().is_none()); + assert!( + perpetual_db + .executed_effects + .get(&tx_digest) + .unwrap() + .is_none() + ); + // `events_2` rows are only deleted for effects that declare an + // events digest; the empty test effects do not, so the seeded row + // stays live (relocation still harvested it below). + assert!(perpetual_db.events_2.get(&tx_digest).unwrap().is_some()); + assert!( + checkpoint_db + .tables + .checkpoint_content + .get(&contents.digest()) + .unwrap() + .is_none() + ); + assert!( + checkpoint_db + .tables + .checkpoint_by_digest + .get(&ckpt_digest) + .unwrap() + .is_none() + ); + + // Everything is served from the historic store. + assert!(historic.get_transaction(&tx_digest).unwrap().is_some()); + assert!(historic.get_effects(&fx_digest).unwrap().is_some()); + assert_eq!( + historic.get_executed_effects(&tx_digest).unwrap(), + Some(fx_digest) + ); + assert!(historic.get_events(&tx_digest).unwrap().is_some()); + assert!( + historic + .get_checkpoint_contents(&contents.digest()) + .unwrap() + .is_some() + ); + assert!( + historic + .get_checkpoint_by_digest(&ckpt_digest) + .unwrap() + .is_some() + ); + assert_eq!( + historic + .get_checkpoint_seq_by_contents_digest(&contents.digest()) + .unwrap(), + Some(9) + ); + assert_eq!(historic.lowest_available_checkpoint().unwrap(), Some(9)); + + // Replaying after a crash between the historic write and the deletes + // (or after completion) converges: sources stay gone, history intact. + run(); + assert!(historic.get_transaction(&tx_digest).unwrap().is_some()); + assert_eq!(historic.lowest_available_checkpoint().unwrap(), Some(9)); + } + + /// The historic buckets live in the perpetual database, so one snapshot + /// covers live and historic column families consistently: every row is + /// in exactly one of them, no matter when relocation ran relative to the + /// snapshot. + #[tokio::test] + async fn db_checkpoint_covers_live_and_historic_consistently() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&db); + let (to_keep, to_delete, _) = generate_test_data(db.clone(), 3, 1, 60).unwrap(); + let (first_half, second_half) = to_delete.split_at(to_delete.len() / 2); + + relocate(&db, &historic, 1, effects_superseding(first_half, &[]), 1).await; + + let restore_dir = iota_common::tempdir(); + db.objects + .checkpoint_db(&restore_dir.path().join("perpetual")) + .unwrap(); + // Relocation after the snapshot must not affect the restored copy. + relocate(&db, &historic, 1, effects_superseding(second_half, &[]), 2).await; + + let restored_db = Arc::new(AuthorityPerpetualTables::open(restore_dir.path(), None)); + let restored_historic = open_historic(&restored_db); + for key in to_keep.iter().chain(&to_delete) { + let in_live = restored_db.objects.get(key).unwrap().is_some(); + let in_history = restored_historic.get_store_object(key).unwrap().is_some(); + assert!( + in_live ^ in_history, + "{key:?} must be in exactly one restored table" + ); + } + // Relocated data reads back as full objects from the restored copy, + // and the captured watermark allows idempotent replay of the torn + // window. + assert!( + restored_historic + .get_object(&first_half[0]) + .unwrap() + .is_some() + ); + assert_eq!( + restored_db.get_highest_pruned_checkpoint().unwrap(), + Some(1) + ); + } + // Tests pruning old version of live objects. #[tokio::test] async fn test_pruning_objects() { @@ -1232,20 +2336,6 @@ mod tests { ); } - // Tests pruning deleted objects (object tombstones). - #[tokio::test] - async fn test_pruning_tombstones() { - let tmp_dir = iota_common::tempdir(); - let to_keep = run_pruner(tmp_dir.path(), 0, 0, 1000).await; - assert_eq!(to_keep.len(), 0); - assert_eq!(get_keys_after_pruning(tmp_dir.path()).unwrap().len(), 0); - - let tmp_dir2 = iota_common::tempdir(); - let to_keep = run_pruner(tmp_dir2.path(), 3, 0, 1000).await; - assert_eq!(to_keep.len(), 0); - assert_eq!(get_keys_after_pruning(tmp_dir2.path()).unwrap().len(), 0); - } - #[cfg(not(target_env = "msvc"))] #[tokio::test] async fn test_db_size_after_compaction() -> Result<(), anyhow::Error> { @@ -1268,27 +2358,25 @@ mod tests { id = id.next_lexicographical(); } - fn get_sst_size(path: &Path) -> u64 { - let mut size = 0; - for entry in std::fs::read_dir(path).unwrap() { - let entry = entry.unwrap(); - let path = entry.path(); - if let Some(ext) = path.extension() { - if ext != "sst" { - continue; - } - size += std::fs::metadata(path).unwrap().len(); - } - } - size + // Relocation moves rows into historic column families of the same + // database, so only the live `objects` column family is expected to + // shrink. + fn objects_cf_size(db: &Arc) -> u64 { + db.objects + .db + .live_files() + .unwrap() + .iter() + .filter(|file| file.column_family_name == "objects") + .map(|file| file.size as u64) + .sum() } - let db_path = tmp_dir.path().join("perpetual"); let start = ObjectKey(ObjectId::ZERO, SequenceNumber::MIN_VALID_INCL); let end = ObjectKey(ObjectId::MAX, SequenceNumber::MAX_VALID_EXCL); perpetual_db.objects.compact_range(&start, &end)?; - let before_compaction_size = get_sst_size(&db_path); + let before_compaction_size = objects_cf_size(&perpetual_db); let mut effects = TransactionEffects::new_empty_v1_for_testing(TransactionDigest::default()); @@ -1301,13 +2389,23 @@ mod tests { } let registry = Registry::default(); let metrics = AuthorityStorePruningMetrics::new(®istry); - let total_pruned = - AuthorityStorePruner::prune_objects(vec![effects], &perpetual_db, None, 0, metrics) - .await; + let historic = open_historic(&perpetual_db); + let total_pruned = AuthorityStorePruner::prune_objects( + vec![effects], + &perpetual_db, + &HistoricRelocation { + store: &historic, + supersession_epoch: 0, + expired: false, + }, + 0, + metrics, + ) + .await; info!("Total pruned keys = {:?}", total_pruned); perpetual_db.objects.compact_range(&start, &end)?; - let after_compaction_size = get_sst_size(&db_path); + let after_compaction_size = objects_cf_size(&perpetual_db); info!( "Before compaction disk size = {:?}, after compaction disk size = {:?}", @@ -1317,65 +2415,6 @@ mod tests { Ok(()) } - /// A legacy V1 row reaching the objects compaction filter (a pre-V2 object - /// left on disk after an in-place upgrade or a V1 formal-snapshot restore) - /// must be migrated before `into_inner()`, which panics on an un-migrated - /// V1 wrapper. - #[tokio::test] - async fn compaction_filter_handles_legacy_v1_row() { - use bincode::Options; - use iota_sdk_types::Owner; - use typed_store::rocksdb::compaction_filter::Decision; - - use super::ObjectsCompactionFilter; - use crate::authority::{ - authority_store_tables::AuthorityPrunerTables, - authority_store_types::{StoreData, StoreObjectV1, StoreObjectValue}, - }; - - // A V1 `Value` row is what a pre-V2 binary wrote for a live object; - // only `Value` rows reach the tombstone lookup. - let object_key = ObjectKey(ObjectId::random(), SequenceNumber::from_u64(1)); - let v1_value = StoreObjectValue { - data: StoreData::Coin(42), - owner: Owner::Immutable, - previous_transaction: TransactionDigest::random(), - storage_rebate: 7, - }; - let key_bytes = bincode::DefaultOptions::new() - .with_big_endian() - .with_fixint_encoding() - .serialize(&object_key) - .unwrap(); - let value_bytes = bcs::to_bytes(&StoreObjectWrapper::V1(StoreObjectV1::Value(Box::new( - v1_value, - )))) - .unwrap(); - - // The filter holds only a `Weak`, so keep a strong ref alive for the - // tombstone lookup to run. - let tmp_dir = iota_common::tempdir(); - let pruner_db = Arc::new(AuthorityPrunerTables::open(tmp_dir.path())); - let mut filter = ObjectsCompactionFilter::new(pruner_db.clone(), &Registry::default()); - - // No tombstone: the row must survive. - let decision = filter - .filter(&key_bytes, &value_bytes) - .expect("legacy V1 row must not panic"); - assert!(matches!(decision, Decision::Keep)); - - // Tombstoned at this version: the row must be compacted away, which - // proves the migrated row reached the tombstone-lookup branch. - pruner_db - .object_tombstones - .insert(&object_key.0, &object_key.1) - .unwrap(); - let decision = filter - .filter(&key_bytes, &value_bytes) - .expect("legacy V1 row must not panic"); - assert!(matches!(decision, Decision::Remove)); - } - /// Builds a single-epoch chain of checkpoints with the given timestamps, /// runs checkpoint pruning with the provided ceiling / retention window / /// cutoff, and returns the resulting `HighestPruned` watermark. @@ -1384,6 +2423,29 @@ mod tests { max_eligible_checkpoint: CheckpointSequenceNumber, cutoff_timestamp_ms: CheckpointTimestamp, num_epochs_to_retain: u64, + ) -> Option { + run_pruning_with_metrics( + timestamps_ms, + max_eligible_checkpoint, + cutoff_timestamp_ms, + num_epochs_to_retain, + PruningMode::Checkpoints, + AuthorityStorePruningMetrics::new_for_test(), + ) + .await + } + + /// Like [`run_checkpoint_pruning`], but with the pruning mode and metrics + /// under the caller's control. Note that all fixture checkpoints share one + /// empty-contents digest, which the checkpoints pass deletes with its + /// first batch — multi-batch runs therefore only work in objects mode. + async fn run_pruning_with_metrics( + timestamps_ms: &[CheckpointTimestamp], + max_eligible_checkpoint: CheckpointSequenceNumber, + cutoff_timestamp_ms: CheckpointTimestamp, + num_epochs_to_retain: u64, + mode: PruningMode, + metrics: Arc, ) -> Option { let perpetual_dir = iota_common::tempdir(); let perpetual_db = Arc::new(AuthorityPerpetualTables::open(perpetual_dir.path(), None)); @@ -1406,14 +2468,15 @@ mod tests { .update_highest_executed_checkpoint(checkpoints.last().unwrap()) .unwrap(); - let registry = Registry::default(); - let metrics = AuthorityStorePruningMetrics::new(®istry); + let historic = open_historic(&perpetual_db); AuthorityStorePruner::prune_for_eligible_epochs( &perpetual_db, &checkpoint_store, None, - None, - PruningMode::Checkpoints, + &historic, + u64::MAX, + true, + mode, num_epochs_to_retain, 0, max_eligible_checkpoint, @@ -1424,9 +2487,12 @@ mod tests { .await .unwrap(); - checkpoint_store - .get_highest_pruned_checkpoint_seq_number() - .unwrap() + match mode { + PruningMode::Objects => perpetual_db.get_highest_pruned_checkpoint().unwrap(), + PruningMode::Checkpoints => checkpoint_store + .get_highest_pruned_checkpoint_seq_number() + .unwrap(), + } } // Checkpoints 1..=9 with timestamps 1000..=9000. The cutoff at 5000 prunes @@ -1475,59 +2541,224 @@ mod tests { assert_eq!(pruned, None); } - // Builds a pruner handle with just the coordination channels (no pruning - // task), for exercising `nudge` / `await_leash` in isolation. - fn coordination_pruner() -> AuthorityStorePruner { - AuthorityStorePruner { - _objects_pruner_cancel_handle: oneshot::channel().0, - executed: watch::channel(0).0, - frontier_ms: watch::channel(u64::MAX).0, + // Each pruning pass publishes the timestamp of the last checkpoint it + // pruned, ending at the timestamp of the checkpoint the run stopped at. + // The objects run crosses a batch boundary (15 checkpoints, cutoff at + // 12000, MAX_CHECKPOINTS_IN_BATCH = 10), so both the per-batch and the + // tail publish sites are exercised. + #[tokio::test] + async fn test_pruning_publishes_last_pruned_timestamp() { + let timestamps: Vec<_> = (1..=15).map(|i| i * 1000).collect(); + let metrics = AuthorityStorePruningMetrics::new_for_test(); + let pruned = run_pruning_with_metrics( + ×tamps, + u64::MAX, + 12_000, + 0, + PruningMode::Objects, + metrics.clone(), + ) + .await; + assert_eq!(pruned, Some(12)); + assert_eq!(metrics.last_pruned_checkpoint_timestamp_ms.get(), 12_000); + // An objects-mode run must not touch the checkpoints-pass gauge. + assert_eq!(metrics.last_pruned_effects_checkpoint_timestamp_ms.get(), 0); + + let timestamps: Vec<_> = (1..=9).map(|i| i * 1000).collect(); + let metrics = AuthorityStorePruningMetrics::new_for_test(); + let pruned = run_pruning_with_metrics( + ×tamps, + u64::MAX, + 5000, + 0, + PruningMode::Checkpoints, + metrics.clone(), + ) + .await; + assert_eq!(pruned, Some(5)); + assert_eq!( + metrics.last_pruned_effects_checkpoint_timestamp_ms.get(), + 5000 + ); + assert_eq!(metrics.last_pruned_checkpoint_timestamp_ms.get(), 0); + } + + /// The one-time sweep empties the live table down to lineage heads: + /// non-heads move into the sweep bucket, value heads stay untouched, and + /// tombstone heads stay live but land in the bucket's expiry list. + #[tokio::test] + async fn legacy_sweep_relocates_non_heads_and_records_tombstone_heads() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&db); + let (to_keep, to_delete, _) = generate_test_data(db.clone(), 3, 1, 100).unwrap(); + + // A deleted lineage: one superseded version below a tombstone head. + let deleted_id = ObjectId::MAX; + let old_key = ObjectKey(deleted_id, SequenceNumber::from_u64(0)); + let tombstone_key = ObjectKey(deleted_id, SequenceNumber::from_u64(1)); + db.objects + .insert( + &old_key, + &get_store_object(Object::immutable_with_id_for_testing(deleted_id), None), + ) + .unwrap(); + db.objects + .insert( + &tombstone_key, + &StoreObjectWrapper::V2(StoreObject::Deleted), + ) + .unwrap(); + + let progress = + AuthorityStorePruner::legacy_sweep_slice(&db, &historic, 7, None, usize::MAX).unwrap(); + assert_eq!(progress, HistoricMigrationProgress::SweepComplete); + assert_eq!( + db.get_historic_migration().unwrap(), + HistoricMigrationProgress::SweepComplete + ); + + let mut expected_live: HashSet<_> = HashSet::from_iter(to_keep); + expected_live.insert(tombstone_key); + assert_eq!(live_keys(&db), expected_live); + for key in to_delete.iter().chain([&old_key]) { + assert!( + historic.get_store_object(key).unwrap().is_some(), + "{key:?} was not swept" + ); } + assert_eq!(historic.tombstone_heads(7).unwrap(), vec![tombstone_key]); } - // The leash passes without blocking while the executed timestamp is within - // the slack of the pruning frontier (and always before the pruner has run, - // when the frontier is u64::MAX). + /// A row budget cuts the sweep at lineage boundaries; the persisted + /// cursor resumes each slice and the end state matches a one-shot sweep. #[tokio::test] - async fn test_leash_passes_within_slack() { - let pruner = coordination_pruner(); - // Frontier starts at u64::MAX: never leashed before the pruner runs. - pruner.await_leash(1_000_000).await; - - pruner.frontier_ms.send_replace(500); - // Gap exactly equals the slack -> still passes. - pruner.await_leash(500 + PRUNING_LEASH_SLACK_MS).await; + async fn legacy_sweep_resumes_across_slices() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&db); + let (to_keep, to_delete, _) = generate_test_data(db.clone(), 3, 1, 10).unwrap(); + + let mut progress = db.get_historic_migration().unwrap(); + assert_eq!( + progress, + HistoricMigrationProgress::Sweeping { resume_after: None } + ); + let mut slices = 0; + while let HistoricMigrationProgress::Sweeping { resume_after } = progress { + progress = AuthorityStorePruner::legacy_sweep_slice(&db, &historic, 3, resume_after, 4) + .unwrap(); + assert_eq!(db.get_historic_migration().unwrap(), progress); + slices += 1; + assert!(slices < 100, "sweep must terminate"); + } + assert_eq!(progress, HistoricMigrationProgress::SweepComplete); + assert!(slices > 1, "the budget must force multiple slices"); + + assert_eq!(live_keys(&db), HashSet::from_iter(to_keep)); + for key in &to_delete { + assert!( + historic.get_store_object(key).unwrap().is_some(), + "{key:?} was not swept" + ); + } } - // The leash blocks while the pruner is more than the slack behind, and - // releases once the frontier advances. + /// Relocation into a bucket already past the retention horizon deletes + /// outright — including the tombstone heads, which the bucket's expiry + /// would have removed anyway — and creates no bucket. #[tokio::test] - async fn test_leash_blocks_until_frontier_advances() { - let pruner = Arc::new(coordination_pruner()); - pruner.frontier_ms.send_replace(0); - let executed_ts = PRUNING_LEASH_SLACK_MS + 10_000; + async fn expired_bucket_relocation_deletes_outright() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&db); + let (_, to_delete, tombstones) = generate_test_data(db.clone(), 3, 0, 10).unwrap(); + + let found = AuthorityStorePruner::prune_objects( + vec![effects_superseding(&to_delete, &tombstones)], + &db, + &HistoricRelocation { + store: &historic, + supersession_epoch: 1, + expired: true, + }, + 1, + AuthorityStorePruningMetrics::new_for_test(), + ) + .await + .unwrap(); - let waiter = pruner.clone(); - let handle = tokio::spawn(async move { waiter.await_leash(executed_ts).await }); + assert_eq!(found, 0); + assert!(live_keys(&db).is_empty()); + assert_eq!(historic.list_epochs(), Vec::::new()); + assert_eq!(db.get_highest_pruned_checkpoint().unwrap(), Some(1)); + } - // Let the spawned task run until it parks on the frontier watch. - tokio::task::yield_now().await; - assert!( - !handle.is_finished(), - "leash must block while the pruner is more than the slack behind" - ); + /// The relocation count reports how many superseded versions were still + /// present — the walker's signal for migration backlog and, after the + /// migration completes, for commit-time capture misses. A replay finds + /// nothing. + #[tokio::test] + async fn walker_reports_versions_found_and_zero_on_replay() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(&db); + let (_, to_delete, _) = generate_test_data(db.clone(), 3, 1, 10).unwrap(); + let effects = effects_superseding(&to_delete, &[]); + + let relocation = HistoricRelocation { + store: &historic, + supersession_epoch: 2, + expired: false, + }; + let found = AuthorityStorePruner::prune_objects( + vec![effects.clone()], + &db, + &relocation, + 1, + AuthorityStorePruningMetrics::new_for_test(), + ) + .await + .unwrap(); + assert_eq!(found, to_delete.len()); + + let found = AuthorityStorePruner::prune_objects( + vec![effects], + &db, + &relocation, + 2, + AuthorityStorePruningMetrics::new_for_test(), + ) + .await + .unwrap(); + assert_eq!(found, 0); + } - // Once the pruner catches up, the leash releases. - pruner.frontier_ms.send_replace(executed_ts); - handle - .await - .expect("leash should release after frontier advances"); + /// A database created at genesis has nothing to migrate: the marker is + /// `Complete` from the start, so the walker's findings immediately count + /// as capture misses and the legacy sweep never runs. + #[tokio::test] + async fn genesis_database_starts_with_migration_complete() { + use crate::authority::test_authority_builder::TestAuthorityBuilder; + + let state = TestAuthorityBuilder::new().build().await; + assert_eq!( + state + .database_for_testing() + .perpetual_tables + .get_historic_migration() + .unwrap(), + HistoricMigrationProgress::Complete + ); } // A nudge wakes the pruner task's subscription. #[tokio::test] async fn test_nudge_wakes_subscriber() { - let pruner = coordination_pruner(); + let pruner = AuthorityStorePruner { + _objects_pruner_cancel_handle: oneshot::channel().0, + executed: watch::channel(0).0, + }; let mut rx = pruner.executed.subscribe(); pruner.nudge(42); rx.changed().await.expect("nudge should notify subscriber"); diff --git a/crates/iota-core/src/authority/authority_store_tables.rs b/crates/iota-core/src/authority/authority_store_tables.rs index afb7ee9432ec..8d697bd95888 100644 --- a/crates/iota-core/src/authority/authority_store_tables.rs +++ b/crates/iota-core/src/authority/authority_store_tables.rs @@ -2,7 +2,7 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::path::Path; +use std::{path::Path, sync::Arc}; use iota_types::{ base_types::SequenceNumber, @@ -12,7 +12,6 @@ use iota_types::{ storage::MarkerValue, }; use serde::{Deserialize, Serialize}; -use tracing::error; use typed_store::{ DBMapUtils, DbIterator, metrics::SamplingInterval, @@ -20,13 +19,11 @@ use typed_store::{ DBBatch, DBMap, DBMapTableConfigMap, DBOptions, MetricConf, default_db_options, read_size_from_env, }, - rocksdb::compaction_filter::Decision, traits::Map, }; use super::*; use crate::authority::{ - authority_store_pruner::ObjectsCompactionFilter, authority_store_types::{ StoreObject, StoreObjectValueV2, StoreObjectWrapper, get_store_object, try_construct_object, }, @@ -44,7 +41,10 @@ const ENV_VAR_EVENTS_BLOCK_CACHE_SIZE: &str = "EVENTS_BLOCK_CACHE_MB"; pub struct AuthorityPerpetualTablesOptions { /// Whether to enable write stalling on all column families. pub enable_write_stall: bool, - pub compaction_filter: Option, + /// Additional column families to open with the given options, e.g. the + /// historic epoch buckets rediscovered from disk. Column families left + /// to auto-discovery would silently get default options. + pub extra_column_families: Vec<(String, DBOptions)>, } impl AuthorityPerpetualTablesOptions { @@ -135,6 +135,11 @@ pub struct AuthorityPerpetualTables { /// objects pruner progress pub(crate) pruned_checkpoint: DBMap<(), CheckpointSequenceNumber>, + /// A singleton table that records the progress of the one-time migration + /// of pre-existing data into the historic epoch buckets, so it resumes + /// across restarts and never reruns once complete. + pub(crate) historic_migration: DBMap<(), HistoricMigrationProgress>, + /// The total IOTA supply and the epoch at which it was stored. /// We check and update it at the end of each epoch if expensive checks are /// enabled. @@ -156,25 +161,24 @@ pub struct AuthorityPerpetualTables { pub(crate) object_per_epoch_marker_table: DBMap<(EpochId, ObjectKey), MarkerValue>, } -#[derive(DBMapUtils)] -pub struct AuthorityPrunerTables { - pub(crate) object_tombstones: DBMap, -} - -impl AuthorityPrunerTables { - pub fn path(parent_path: &Path) -> PathBuf { - parent_path.join("pruner") - } - - pub fn open(parent_path: &Path) -> Self { - Self::open_tables_read_write( - Self::path(parent_path), - MetricConf::new("pruner") - .with_sampling(SamplingInterval::new(Duration::from_secs(60), 0)), - None, - None, - ) - } +/// Progress of the one-time migration that moves pre-existing superseded +/// object versions into the historic epoch buckets. Databases created by a +/// version with commit-time relocation start out `Complete`; upgraded +/// databases walk through the states below exactly once. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum HistoricMigrationProgress { + /// The one-time sweep over the live objects table is in progress; it + /// resumes with the object id following this one. `None` means the sweep + /// has not processed any object yet. + Sweeping { resume_after: Option }, + /// The sweep finished; the checkpoint walker has not yet confirmed that + /// no pre-existing superseded versions remain below the executed + /// watermark. + SweepComplete, + /// Every pre-existing superseded version has been relocated. From here + /// on, any version the walker still finds in the live table is a + /// commit-time capture miss. + Complete, } /// The total IOTA supply used during conservation checks. @@ -198,10 +202,10 @@ impl AuthorityPerpetualTables { let db_options_override = db_options_override.unwrap_or_default(); let db_options = db_options_override.apply_to(default_db_options().optimize_db_for_write_throughput(4)); - let table_options = DBMapTableConfigMap::new(BTreeMap::from([ + let mut table_options_map = BTreeMap::from([ ( "objects".to_string(), - objects_table_config(db_options.clone(), db_options_override.compaction_filter), + objects_table_config(db_options.clone()), ), ( "live_owned_object_markers".to_string(), @@ -219,7 +223,11 @@ impl AuthorityPerpetualTables { "events".to_string(), events_table_config(db_options.clone()), ), - ])); + ]); + for (name, options) in &db_options_override.extra_column_families { + table_options_map.insert(name.clone(), options.clone()); + } + let table_options = DBMapTableConfigMap::new(table_options_map); Self::open_tables_read_write( Self::path(parent_path), MetricConf::new("perpetual") @@ -229,6 +237,12 @@ impl AuthorityPerpetualTables { ) } + /// Handle to the underlying database, shared by every table. Used to + /// attach the historic store's epoch buckets to the same database. + pub fn database(&self) -> Arc { + self.objects.db.clone() + } + pub fn open_readonly(parent_path: &Path) -> AuthorityPerpetualTablesReadOnly { Self::get_read_only_handle( Self::path(parent_path), @@ -435,6 +449,20 @@ impl AuthorityPerpetualTables { Ok(()) } + /// The recorded migration progress; a database from before the migration + /// marker existed has no row and starts sweeping from the beginning. + pub fn get_historic_migration(&self) -> IotaResult { + Ok(self + .historic_migration + .get(&())? + .unwrap_or(HistoricMigrationProgress::Sweeping { resume_after: None })) + } + + pub fn set_historic_migration(&self, progress: HistoricMigrationProgress) -> IotaResult { + self.historic_migration.insert(&(), &progress)?; + Ok(()) + } + pub fn database_is_empty(&self) -> IotaResult { Ok(self.objects.safe_iter().next().is_none()) } @@ -685,23 +713,7 @@ fn live_owned_object_markers_table_config(db_options: DBOptions) -> DBOptions { } } -fn objects_table_config( - mut db_options: DBOptions, - compaction_filter: Option, -) -> DBOptions { - if let Some(mut compaction_filter) = compaction_filter { - db_options - .options - .set_compaction_filter("objects", move |_, key, value| { - match compaction_filter.filter(key, value) { - Ok(decision) => decision, - Err(err) => { - error!("Compaction error: {:?}", err); - Decision::Keep - } - } - }); - } +fn objects_table_config(db_options: DBOptions) -> DBOptions { db_options .optimize_for_write_throughput() .optimize_for_read(read_size_from_env(ENV_VAR_OBJECTS_BLOCK_CACHE_SIZE).unwrap_or(5 * 1024)) diff --git a/crates/iota-core/src/authority/historic_store.rs b/crates/iota-core/src/authority/historic_store.rs new file mode 100644 index 000000000000..83d78d4be620 --- /dev/null +++ b/crates/iota-core/src/authority/historic_store.rs @@ -0,0 +1,1026 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Per-epoch storage for pruned historic data. +//! +//! When the live/historic split is enabled, the pruner relocates data into +//! this store instead of deleting it: +//! +//! - superseded object versions, bucketed by their *supersession epoch* (the +//! epoch of the checkpoint whose effects superseded them); +//! - checkpoint-keyed history (transactions, effects, events, checkpoint +//! contents and summaries), bucketed by the epoch of their checkpoint. +//! +//! Each epoch bucket is a fixed set of column families, so expiring an epoch +//! of history is a constant-time `drop_cf` per family instead of per-key +//! deletes. +//! +//! The store is strictly outside the consensus/execution write and read +//! paths: readers are the gRPC exact-version object lookup and the +//! RocksDbStore fallbacks serving old transactions/effects/checkpoints to +//! gRPC and state sync. Lookups carry no epoch hint, so they probe the +//! per-epoch column families newest to oldest; a miss in a sealed, compacted +//! column family is answered from the in-memory RocksDB bloom filters without +//! touching disk. + +use std::{ + collections::BTreeMap, + path::Path, + sync::{Arc, RwLock}, +}; + +use iota_types::{ + base_types::EpochId, + digests::{TransactionDigest, TransactionEffectsDigest}, + effects::{TransactionEffects, TransactionEvents}, + error::{IotaError, IotaResult}, + messages_checkpoint::{ + CheckpointContents, CheckpointContentsDigest, CheckpointDigest, CheckpointSequenceNumber, + TrustedCheckpoint, + }, + object::Object, + storage::ObjectKey, + transaction::TrustedTransaction, +}; +use prometheus_filtered::{ + Histogram, IntCounter, IntGauge, Registry, register_histogram_with_registry, + register_int_counter_with_registry, register_int_gauge_with_registry, +}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use typed_store::{ + Map, + database::Database, + rocks::{ + DBBatch, DBMap, DBOptions, ReadWriteOptions, default_db_options, list_tables, + read_size_from_env, + }, + rocksdb, +}; + +use crate::authority::authority_store_types::{StoreObject, StoreObjectWrapper}; + +const META_CF_NAME: &str = "hist_meta"; +const OBJECTS_CF_PREFIX: &str = "hist_obj_e"; +const EXPIRY_CF_PREFIX: &str = "hist_exp_e"; +const TRANSACTIONS_CF_PREFIX: &str = "hist_tx_e"; +const EFFECTS_CF_PREFIX: &str = "hist_fx_e"; +const EXECUTED_EFFECTS_CF_PREFIX: &str = "hist_exec_fx_e"; +const EVENTS_CF_PREFIX: &str = "hist_ev_e"; +const CHECKPOINT_CONTENTS_CF_PREFIX: &str = "hist_ckpt_content_e"; +const CHECKPOINT_SEQ_CF_PREFIX: &str = "hist_ckpt_seq_e"; +const CHECKPOINTS_CF_PREFIX: &str = "hist_ckpt_e"; + +/// Every column-family prefix of an epoch bucket. No prefix may be a prefix +/// of another followed by a digit, so parsing an epoch from a name is +/// unambiguous. +const EPOCH_CF_PREFIXES: [&str; 9] = [ + OBJECTS_CF_PREFIX, + EXPIRY_CF_PREFIX, + TRANSACTIONS_CF_PREFIX, + EFFECTS_CF_PREFIX, + EXECUTED_EFFECTS_CF_PREFIX, + EVENTS_CF_PREFIX, + CHECKPOINT_CONTENTS_CF_PREFIX, + CHECKPOINT_SEQ_CF_PREFIX, + CHECKPOINTS_CF_PREFIX, +]; + +const ENV_VAR_HISTORY_BLOCK_CACHE_SIZE: &str = "HISTORY_BLOCK_CACHE_MB"; +const DEFAULT_HISTORY_BLOCK_CACHE_SIZE_MB: usize = 512; + +/// Durable per-epoch bookkeeping, kept in the always-open `meta` column +/// family. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EpochBucketInfo { + /// Set once the pruner has moved past this epoch; a sealed bucket never + /// receives writes again. + pub sealed: bool, + /// Number of relocated object versions in the bucket. + pub object_count: u64, + /// Number of tombstone-head expiry entries in the bucket. + pub expiry_count: u64, + /// Lowest checkpoint whose checkpoint-keyed history was relocated into + /// this bucket. `None` until checkpoint relocation reaches this epoch. + /// The earliest bucket may cover its epoch only partially (relocation + /// enabled mid-epoch), so this — not the epoch's first checkpoint — is + /// the availability horizon. + pub min_checkpoint: Option, + /// Highest checkpoint whose checkpoint-keyed history was relocated into + /// this bucket. + pub max_checkpoint: Option, +} + +struct EpochBucket { + /// In-memory copy of the bucket's `meta` row, updated on every staged + /// write and persisted within the same batch, so staging never reads the + /// database. The single-writer commit/pruner path keeps it coherent. + info: std::sync::Mutex, + /// Superseded object versions relocated out of the live `objects` table. + objects: DBMap, + /// Tombstone heads (`Deleted`/`Wrapped`) whose lineages were superseded in + /// this epoch. They stay in the live table until this bucket expires, at + /// which point they are point-deleted from the live table right before + /// the bucket is dropped. + expiry: DBMap, + /// Transactions of this epoch's pruned checkpoints. + transactions: DBMap, + /// Effects of this epoch's pruned checkpoints, by effects digest. + effects: DBMap, + /// Transaction digest to executed effects digest. + executed_effects: DBMap, + /// Events by the digest of the transaction that produced them. + events: DBMap, + /// Checkpoint contents by contents digest. + checkpoint_contents: DBMap, + /// Checkpoint contents digest to checkpoint sequence number. + checkpoint_seq_by_contents: DBMap, + /// Certified checkpoint summaries by checkpoint digest. + checkpoints: DBMap, +} + +/// One epoch-homogeneous batch of checkpoint-keyed history to relocate. +/// All keys must belong to checkpoints of the target bucket's epoch. +#[derive(Default)] +pub struct CheckpointHistoryBatch { + pub transactions: Vec<(TransactionDigest, TrustedTransaction)>, + pub effects: Vec<(TransactionEffectsDigest, TransactionEffects)>, + pub executed_effects: Vec<(TransactionDigest, TransactionEffectsDigest)>, + pub events: Vec<(TransactionDigest, TransactionEvents)>, + pub checkpoint_contents: Vec<(CheckpointContentsDigest, CheckpointContents)>, + pub checkpoint_seq_by_contents: Vec<(CheckpointContentsDigest, CheckpointSequenceNumber)>, + pub checkpoints: Vec<(CheckpointDigest, TrustedCheckpoint)>, + /// Inclusive checkpoint sequence range covered by this batch; drives the + /// bucket's availability watermark. + pub checkpoint_range: Option<(CheckpointSequenceNumber, CheckpointSequenceNumber)>, +} + +impl EpochBucket { + fn flush_all(&self) -> IotaResult<()> { + self.objects.flush()?; + self.expiry.flush()?; + self.transactions.flush()?; + self.effects.flush()?; + self.executed_effects.flush()?; + self.events.flush()?; + self.checkpoint_contents.flush()?; + self.checkpoint_seq_by_contents.flush()?; + self.checkpoints.flush()?; + Ok(()) + } +} + +impl CheckpointHistoryBatch { + pub fn is_empty(&self) -> bool { + self.transactions.is_empty() + && self.effects.is_empty() + && self.executed_effects.is_empty() + && self.events.is_empty() + && self.checkpoint_contents.is_empty() + && self.checkpoint_seq_by_contents.is_empty() + && self.checkpoints.is_empty() + } +} + +pub struct HistoricStoreMetrics { + pub relocated_objects: IntCounter, + pub relocated_transactions: IntCounter, + pub relocated_bytes: IntCounter, + pub lookup_probes: Histogram, + pub lookup_not_found: IntCounter, + pub epochs_retained: IntGauge, + pub earliest_retained_epoch: IntGauge, +} + +impl HistoricStoreMetrics { + pub fn new(registry: &Registry) -> Arc { + Arc::new(Self { + relocated_objects: register_int_counter_with_registry!( + "historic_store_relocated_objects", + "Number of superseded object versions relocated into the historic store", + registry + ) + .unwrap(), + relocated_transactions: register_int_counter_with_registry!( + "historic_store_relocated_transactions", + "Number of transactions whose checkpoint-keyed history was relocated into the \ + historic store", + registry + ) + .unwrap(), + relocated_bytes: register_int_counter_with_registry!( + "historic_store_relocated_bytes", + "Serialized bytes of object versions relocated into the historic store", + registry + ) + .unwrap(), + lookup_probes: register_histogram_with_registry!( + "historic_store_lookup_probes", + "Number of epoch buckets probed per historic lookup", + vec![1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0], + registry + ) + .unwrap(), + lookup_not_found: register_int_counter_with_registry!( + "historic_store_lookup_not_found", + "Historic lookups that missed every epoch bucket", + registry + ) + .unwrap(), + epochs_retained: register_int_gauge_with_registry!( + "historic_store_epochs_retained", + "Number of epoch buckets currently retained", + registry + ) + .unwrap(), + earliest_retained_epoch: register_int_gauge_with_registry!( + "historic_store_earliest_retained_epoch", + "Earliest epoch with a retained bucket", + registry + ) + .unwrap(), + }) + } + + pub fn new_for_test() -> Arc { + Self::new(&Registry::new()) + } +} + +/// Store of superseded object versions and checkpoint-keyed history, +/// bucketed by epoch in column families of the *perpetual* database, so +/// relocation participates in the same atomic write batches as the live +/// tables. +/// +/// Writes come from the checkpoint commit path and the pruner; reads may +/// come from any number of RPC threads concurrently. +pub struct HistoricStore { + db: Arc, + /// Template options for per-epoch column families. All clones share one + /// block cache through the cloned table factory. + cf_options: rocksdb::Options, + meta: DBMap, + buckets: RwLock>, + metrics: Arc, +} + +impl HistoricStore { + /// Tuned options for every historic column family present at + /// `perpetual_path`, plus the meta column family. Must be passed into the + /// perpetual store's open (as extra column families) so rediscovered + /// buckets keep their bloom filters and compaction style; column families + /// left to auto-discovery would silently get default options. + pub fn extra_column_family_options(perpetual_path: &Path) -> Vec<(String, DBOptions)> { + let cf_options = Self::epoch_cf_options(&default_db_options()); + let meta_options = default_db_options().optimize_for_point_lookup(8); + let mut extras = vec![(META_CF_NAME.to_owned(), meta_options)]; + for cf_name in list_tables(perpetual_path.to_path_buf()).unwrap_or_default() { + if EPOCH_CF_PREFIXES + .iter() + .any(|prefix| cf_name.strip_prefix(prefix).is_some()) + { + extras.push(( + cf_name, + DBOptions { + options: cf_options.clone(), + rw_options: ReadWriteOptions::default(), + }, + )); + } + } + extras + } + + /// Attaches the historic store to the already-open perpetual database, + /// rediscovering all per-epoch column families. The database must have + /// been opened with [`Self::extra_column_family_options`]. + pub fn new_shared(db: Arc, metrics: Arc) -> IotaResult { + let cf_options = Self::epoch_cf_options(&default_db_options()); + if db.cf_handle(META_CF_NAME).is_none() { + let meta_options = default_db_options().optimize_for_point_lookup(8); + db.create_cf(META_CF_NAME, &meta_options.options) + .map_err(|e| IotaError::Storage(e.to_string()))?; + } + let meta = DBMap::reopen(&db, Some(META_CF_NAME), &ReadWriteOptions::default(), false)?; + + // Column family names are the ground truth for which buckets exist; + // `meta` may lag by one crash (bucket created, meta row not yet + // written) and is backfilled lazily on the next write. A bucket's + // column families are created and dropped in separate operations, so + // a crash can leave some of the set missing: recreate them (empty) + // here. A bucket half-dropped this way simply resurfaces and is + // dropped again by the next retention pass. + let mut epochs = std::collections::BTreeSet::new(); + for cf_name in db.column_family_names() { + let Some(epoch_str) = EPOCH_CF_PREFIXES + .iter() + .find_map(|prefix| cf_name.strip_prefix(prefix)) + else { + continue; + }; + let epoch: EpochId = epoch_str.parse().map_err(|_| { + IotaError::Storage(format!("unparsable historic column family name: {cf_name}")) + })?; + epochs.insert(epoch); + } + let mut buckets = BTreeMap::new(); + for epoch in epochs { + for cf_name in Self::epoch_cf_names(epoch) { + if db.cf_handle(&cf_name).is_none() { + db.create_cf(&cf_name, &cf_options) + .map_err(|e| IotaError::Storage(e.to_string()))?; + } + } + buckets.insert(epoch, Self::reopen_bucket(&db, &meta, epoch)?); + } + + let store = Self { + db, + cf_options, + meta, + buckets: RwLock::new(buckets), + metrics, + }; + store.update_retention_metrics(); + Ok(store) + } + + fn epoch_cf_options(db_options: &DBOptions) -> rocksdb::Options { + // Relocation writes are append-only per bucket (universal compaction, + // no deletions), values are large (blob files), and reads are exact + // key lookups answered by the block-based bloom filters, which the + // block options pin in RAM. `set_block_options` creates the single + // block cache that every clone of these options shares. + db_options + .clone() + .optimize_for_write_throughput_no_deletion() + .optimize_for_large_values_no_scan(1 << 10) + .set_block_options( + read_size_from_env(ENV_VAR_HISTORY_BLOCK_CACHE_SIZE) + .unwrap_or(DEFAULT_HISTORY_BLOCK_CACHE_SIZE_MB), + 16 << 10, + ) + .options + } + + fn objects_cf_name(epoch: EpochId) -> String { + format!("{OBJECTS_CF_PREFIX}{epoch}") + } + + fn expiry_cf_name(epoch: EpochId) -> String { + format!("{EXPIRY_CF_PREFIX}{epoch}") + } + + fn epoch_cf_names(epoch: EpochId) -> [String; 9] { + EPOCH_CF_PREFIXES.map(|prefix| format!("{prefix}{epoch}")) + } + + fn reopen_bucket( + db: &Arc, + meta: &DBMap, + epoch: EpochId, + ) -> IotaResult { + // Per-epoch column families skip the periodic metrics reporter task: + // with ~100 retained epochs the per-table metrics add little insight + // and one task per column family adds up. + fn map(db: &Arc, cf_name: String) -> IotaResult> { + Ok(DBMap::reopen( + db, + Some(&cf_name), + &ReadWriteOptions::default(), + true, + )?) + } + Ok(EpochBucket { + info: std::sync::Mutex::new(meta.get(&epoch)?.unwrap_or_default()), + objects: map(db, Self::objects_cf_name(epoch))?, + expiry: map(db, Self::expiry_cf_name(epoch))?, + transactions: map(db, format!("{TRANSACTIONS_CF_PREFIX}{epoch}"))?, + effects: map(db, format!("{EFFECTS_CF_PREFIX}{epoch}"))?, + executed_effects: map(db, format!("{EXECUTED_EFFECTS_CF_PREFIX}{epoch}"))?, + events: map(db, format!("{EVENTS_CF_PREFIX}{epoch}"))?, + checkpoint_contents: map(db, format!("{CHECKPOINT_CONTENTS_CF_PREFIX}{epoch}"))?, + checkpoint_seq_by_contents: map(db, format!("{CHECKPOINT_SEQ_CF_PREFIX}{epoch}"))?, + checkpoints: map(db, format!("{CHECKPOINTS_CF_PREFIX}{epoch}"))?, + }) + } + + fn update_retention_metrics(&self) { + let buckets = self.buckets.read().expect("lock should not be poisoned"); + self.metrics.epochs_retained.set(buckets.len() as i64); + if let Some((&earliest, _)) = buckets.first_key_value() { + self.metrics.earliest_retained_epoch.set(earliest as i64); + } + } + + /// Makes the bucket for `epoch` exist so that a subsequent write batch + /// can reference its column families. Column-family creation is not part + /// of a write batch, so callers must invoke this before building the + /// batch they stage into. + pub fn prepare_bucket(&self, epoch: EpochId) -> IotaResult<()> { + self.ensure_bucket(epoch) + } + + /// Stages relocated rows and the tombstone-head expiry list for + /// `supersession_epoch` into `batch` — the same atomic batch that deletes + /// the rows from the live table, so relocation is crash-atomic. + /// Idempotent: rewriting the same keys with the same bytes is harmless. + /// + /// [`Self::prepare_bucket`] must have been called for the epoch. + pub fn stage_objects( + &self, + batch: &mut DBBatch, + supersession_epoch: EpochId, + objects: &[(ObjectKey, StoreObjectWrapper)], + tombstone_heads: &[ObjectKey], + ) -> IotaResult<()> { + if objects.is_empty() && tombstone_heads.is_empty() { + return Ok(()); + } + let buckets = self.buckets.read().expect("lock should not be poisoned"); + let bucket = buckets + .get(&supersession_epoch) + .expect("prepare_bucket must be called before staging"); + + batch.insert_batch(&bucket.objects, objects.iter().map(|(k, v)| (k, v)))?; + batch.insert_batch(&bucket.expiry, tombstone_heads.iter().map(|k| (k, ())))?; + + let info = { + let mut info = bucket.info.lock().expect("lock should not be poisoned"); + info.object_count += objects.len() as u64; + info.expiry_count += tombstone_heads.len() as u64; + info.clone() + }; + batch.insert_batch(&self.meta, [(supersession_epoch, info)])?; + + self.metrics.relocated_objects.inc_by(objects.len() as u64); + let relocated_bytes: u64 = objects + .iter() + .map(|(_, value)| bcs::serialized_size(value).unwrap_or_default() as u64) + .sum(); + self.metrics.relocated_bytes.inc_by(relocated_bytes); + Ok(()) + } + + /// Writes relocated rows in their own batch. See [`Self::stage_objects`]. + pub fn put_objects( + &self, + supersession_epoch: EpochId, + objects: &[(ObjectKey, StoreObjectWrapper)], + tombstone_heads: &[ObjectKey], + ) -> IotaResult<()> { + if objects.is_empty() && tombstone_heads.is_empty() { + return Ok(()); + } + self.prepare_bucket(supersession_epoch)?; + let mut batch = self.meta.batch(); + self.stage_objects(&mut batch, supersession_epoch, objects, tombstone_heads)?; + batch.write()?; + Ok(()) + } + + /// Stages one epoch-homogeneous batch of checkpoint-keyed history for + /// `epoch` into `batch` — the same atomic batch that deletes the source + /// rows. Idempotent: rewriting the same keys with the same bytes is + /// harmless. + /// + /// [`Self::prepare_bucket`] must have been called for the epoch. + pub fn stage_checkpoint_data( + &self, + batch: &mut DBBatch, + epoch: EpochId, + data: CheckpointHistoryBatch, + ) -> IotaResult<()> { + if data.is_empty() { + return Ok(()); + } + let buckets = self.buckets.read().expect("lock should not be poisoned"); + let bucket = buckets + .get(&epoch) + .expect("prepare_bucket must be called before staging"); + + let num_transactions = data.transactions.len() as u64; + batch.insert_batch(&bucket.transactions, data.transactions)?; + batch.insert_batch(&bucket.effects, data.effects)?; + batch.insert_batch(&bucket.executed_effects, data.executed_effects)?; + batch.insert_batch(&bucket.events, data.events)?; + batch.insert_batch(&bucket.checkpoint_contents, data.checkpoint_contents)?; + batch.insert_batch( + &bucket.checkpoint_seq_by_contents, + data.checkpoint_seq_by_contents, + )?; + batch.insert_batch(&bucket.checkpoints, data.checkpoints)?; + + let info = { + let mut info = bucket.info.lock().expect("lock should not be poisoned"); + if let Some((batch_min, batch_max)) = data.checkpoint_range { + info.min_checkpoint = Some(info.min_checkpoint.unwrap_or(batch_min).min(batch_min)); + info.max_checkpoint = Some(info.max_checkpoint.unwrap_or(batch_max).max(batch_max)); + } + info.clone() + }; + batch.insert_batch(&self.meta, [(epoch, info)])?; + + self.metrics.relocated_transactions.inc_by(num_transactions); + Ok(()) + } + + /// Writes checkpoint-keyed history in its own batch. See + /// [`Self::stage_checkpoint_data`]. + pub fn put_checkpoint_data( + &self, + epoch: EpochId, + data: CheckpointHistoryBatch, + ) -> IotaResult<()> { + if data.is_empty() { + return Ok(()); + } + self.prepare_bucket(epoch)?; + let mut batch = self.meta.batch(); + self.stage_checkpoint_data(&mut batch, epoch, data)?; + batch.write()?; + Ok(()) + } + + /// Seals a bucket once the pruner has moved past its epoch: flushes it, + /// compacts it into its final sorted run, and records the seal. Sealing + /// is idempotent; a sealed bucket never receives writes again. + pub fn seal_epoch(&self, epoch: EpochId) -> IotaResult<()> { + { + let buckets = self.buckets.read().expect("lock should not be poisoned"); + let Some(bucket) = buckets.get(&epoch) else { + return Ok(()); + }; + bucket.flush_all()?; + // Full-range manual compaction: the longest keys are 40-byte + // fix-int-serialized (ObjectId, version) tuples, so these raw + // bounds cover every possible key. + let full_range_end = vec![0xffu8; 48]; + for cf_name in Self::epoch_cf_names(epoch) { + bucket + .objects + .compact_range_raw(&cf_name, vec![], full_range_end.clone())?; + } + } + let buckets = self.buckets.read().expect("lock should not be poisoned"); + if let Some(bucket) = buckets.get(&epoch) { + let info = { + let mut info = bucket.info.lock().expect("lock should not be poisoned"); + info.sealed = true; + info.clone() + }; + self.meta.insert(&epoch, &info)?; + } + Ok(()) + } + + /// Exact-key lookup with no epoch hint: probes buckets newest to oldest. + fn probe_newest_first( + &self, + select: impl Fn(&EpochBucket) -> &DBMap, + key: &K, + ) -> IotaResult> + where + K: Serialize + DeserializeOwned, + V: Serialize + DeserializeOwned, + { + let buckets = self.buckets.read().expect("lock should not be poisoned"); + let mut probes = 0u64; + for bucket in buckets.values().rev() { + probes += 1; + if let Some(value) = select(bucket).get(key)? { + self.metrics.lookup_probes.observe(probes as f64); + return Ok(Some(value)); + } + } + self.metrics.lookup_probes.observe(probes.max(1) as f64); + self.metrics.lookup_not_found.inc(); + Ok(None) + } + + pub fn get_store_object(&self, key: &ObjectKey) -> IotaResult> { + self.probe_newest_first(|bucket| &bucket.objects, key) + } + + pub fn get_transaction( + &self, + digest: &TransactionDigest, + ) -> IotaResult> { + self.probe_newest_first(|bucket| &bucket.transactions, digest) + } + + pub fn get_effects( + &self, + digest: &TransactionEffectsDigest, + ) -> IotaResult> { + self.probe_newest_first(|bucket| &bucket.effects, digest) + } + + pub fn get_executed_effects( + &self, + digest: &TransactionDigest, + ) -> IotaResult> { + self.probe_newest_first(|bucket| &bucket.executed_effects, digest) + } + + pub fn get_events(&self, digest: &TransactionDigest) -> IotaResult> { + self.probe_newest_first(|bucket| &bucket.events, digest) + } + + pub fn get_checkpoint_contents( + &self, + digest: &CheckpointContentsDigest, + ) -> IotaResult> { + self.probe_newest_first(|bucket| &bucket.checkpoint_contents, digest) + } + + pub fn get_checkpoint_seq_by_contents_digest( + &self, + digest: &CheckpointContentsDigest, + ) -> IotaResult> { + self.probe_newest_first(|bucket| &bucket.checkpoint_seq_by_contents, digest) + } + + pub fn get_checkpoint_by_digest( + &self, + digest: &CheckpointDigest, + ) -> IotaResult> { + self.probe_newest_first(|bucket| &bucket.checkpoints, digest) + } + + /// The lowest checkpoint whose checkpoint-keyed history is retained, if + /// any. Coverage is contiguous from here to the pruning watermark: + /// relocation processes checkpoints strictly in order, and buckets expire + /// oldest-first. + pub fn lowest_available_checkpoint(&self) -> IotaResult> { + for entry in self.meta.safe_iter() { + let (_, info) = entry?; + if let Some(min_checkpoint) = info.min_checkpoint { + return Ok(Some(min_checkpoint)); + } + } + Ok(None) + } + + /// Like [`Self::get_store_object`], constructing the full object. + /// Returns `None` for tombstone rows, mirroring the live table's read + /// semantics. + pub fn get_object(&self, key: &ObjectKey) -> IotaResult> { + let Some(wrapper) = self.get_store_object(key)? else { + return Ok(None); + }; + let StoreObject::Value(store_object) = wrapper.migrate().into_inner() else { + return Ok(None); + }; + Ok(Some( + crate::authority::authority_store_types::try_construct_object(key, *store_object)?, + )) + } + + /// The tombstone heads recorded for `epoch`. These are the live-table + /// keys that must be point-deleted when the bucket expires. + pub fn tombstone_heads(&self, epoch: EpochId) -> IotaResult> { + let buckets = self.buckets.read().expect("lock should not be poisoned"); + let Some(bucket) = buckets.get(&epoch) else { + return Ok(Vec::new()); + }; + bucket + .expiry + .safe_iter() + .map(|entry| entry.map(|(key, ())| key).map_err(IotaError::from)) + .collect() + } + + /// Drops the bucket for `epoch` wholesale. Idempotent. Callers must have + /// already deleted the bucket's tombstone heads from the live table. + pub fn drop_epoch(&self, epoch: EpochId) -> IotaResult<()> { + let removed = { + let mut buckets = self.buckets.write().expect("lock should not be poisoned"); + buckets.remove(&epoch) + }; + if removed.is_some() { + for cf_name in Self::epoch_cf_names(epoch) { + self.db + .drop_cf(&cf_name) + .map_err(|e| IotaError::Storage(e.to_string()))?; + } + } + self.meta.remove(&epoch)?; + self.update_retention_metrics(); + Ok(()) + } + + /// The earliest epoch with a retained bucket, i.e. the historic + /// availability horizon. + pub fn earliest_epoch(&self) -> Option { + self.buckets + .read() + .expect("lock should not be poisoned") + .first_key_value() + .map(|(&epoch, _)| epoch) + } + + /// All epochs with retained buckets, ascending. + pub fn list_epochs(&self) -> Vec { + self.buckets + .read() + .expect("lock should not be poisoned") + .keys() + .copied() + .collect() + } + + /// Whether the bucket for `epoch` has been sealed. + pub fn is_sealed(&self, epoch: EpochId) -> IotaResult { + Ok(self.meta.get(&epoch)?.is_some_and(|info| info.sealed)) + } + + fn ensure_bucket(&self, epoch: EpochId) -> IotaResult<()> { + { + let buckets = self.buckets.read().expect("lock should not be poisoned"); + if buckets.contains_key(&epoch) { + return Ok(()); + } + } + let mut buckets = self.buckets.write().expect("lock should not be poisoned"); + if buckets.contains_key(&epoch) { + return Ok(()); + } + for cf_name in Self::epoch_cf_names(epoch) { + // The column family may already exist if a previous run crashed + // between `create_cf` and the first batch write. + if self.db.cf_handle(&cf_name).is_none() { + self.db + .create_cf(&cf_name, &self.cf_options) + .map_err(|e| IotaError::Storage(e.to_string()))?; + } + } + buckets.insert(epoch, Self::reopen_bucket(&self.db, &self.meta, epoch)?); + drop(buckets); + self.update_retention_metrics(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use iota_sdk_types::ObjectId; + use iota_types::base_types::SequenceNumber; + + use super::*; + use crate::authority::authority_store_types::get_store_object; + + fn open_store(path: &Path) -> HistoricStore { + let extras = HistoricStore::extra_column_family_options(path); + let opt_cfs: Vec<(&str, rocksdb::Options)> = extras + .iter() + .map(|(name, options)| (name.as_str(), options.options.clone())) + .collect(); + let db = typed_store::rocks::open_cf_opts( + path, + None, + typed_store::rocks::MetricConf::new("historic_test"), + &opt_cfs, + ) + .unwrap(); + HistoricStore::new_shared(db, HistoricStoreMetrics::new_for_test()).unwrap() + } + + fn test_row(version: u64) -> (ObjectKey, StoreObjectWrapper) { + let object = Object::immutable_with_id_for_testing(ObjectId::random()); + let key = ObjectKey(object.id(), SequenceNumber::from_u64(version)); + (key, get_store_object(object, None)) + } + + #[tokio::test] + async fn put_get_roundtrip_probes_newest_first() { + let tmp_dir = iota_common::tempdir(); + let store = open_store(tmp_dir.path()); + + let (key_e1, row_e1) = test_row(1); + let (key_e5, row_e5) = test_row(3); + store.put_objects(1, &[(key_e1, row_e1)], &[]).unwrap(); + store.put_objects(5, &[(key_e5, row_e5)], &[]).unwrap(); + + assert!(store.get_object(&key_e1).unwrap().is_some()); + assert!(store.get_object(&key_e5).unwrap().is_some()); + let (missing_key, _) = test_row(9); + assert!(store.get_object(&missing_key).unwrap().is_none()); + assert_eq!(store.list_epochs(), vec![1, 5]); + assert_eq!(store.earliest_epoch(), Some(1)); + } + + #[tokio::test] + async fn restart_rediscovers_buckets_and_seal_state() { + let tmp_dir = iota_common::tempdir(); + let (key, row) = test_row(2); + { + let store = open_store(tmp_dir.path()); + store + .put_objects( + 7, + &[(key, row)], + &[ObjectKey(key.0, SequenceNumber::from_u64(3))], + ) + .unwrap(); + store.seal_epoch(7).unwrap(); + assert!(store.is_sealed(7).unwrap()); + } + let store = open_store(tmp_dir.path()); + assert_eq!(store.list_epochs(), vec![7]); + assert!(store.is_sealed(7).unwrap()); + assert!(store.get_object(&key).unwrap().is_some()); + assert_eq!( + store.tombstone_heads(7).unwrap(), + vec![ObjectKey(key.0, SequenceNumber::from_u64(3))] + ); + } + + #[tokio::test] + async fn seal_epoch_is_idempotent() { + let tmp_dir = iota_common::tempdir(); + let store = open_store(tmp_dir.path()); + let (key, row) = test_row(1); + store.put_objects(3, &[(key, row)], &[]).unwrap(); + store.seal_epoch(3).unwrap(); + store.seal_epoch(3).unwrap(); + assert!(store.is_sealed(3).unwrap()); + assert!(store.get_object(&key).unwrap().is_some()); + } + + #[tokio::test] + async fn drop_epoch_removes_bucket_and_is_idempotent() { + let tmp_dir = iota_common::tempdir(); + let store = open_store(tmp_dir.path()); + let (key_a, row_a) = test_row(1); + let (key_b, row_b) = test_row(2); + store.put_objects(1, &[(key_a, row_a)], &[]).unwrap(); + store.put_objects(2, &[(key_b, row_b)], &[]).unwrap(); + + store.drop_epoch(1).unwrap(); + assert!(store.get_object(&key_a).unwrap().is_none()); + assert!(store.get_object(&key_b).unwrap().is_some()); + assert_eq!(store.earliest_epoch(), Some(2)); + // Dropping again is a no-op. + store.drop_epoch(1).unwrap(); + + // The dropped bucket stays gone across restarts. + drop(store); + let store = open_store(tmp_dir.path()); + assert_eq!(store.list_epochs(), vec![2]); + assert!(store.get_object(&key_b).unwrap().is_some()); + } + + #[tokio::test] + async fn checkpoint_history_roundtrip_and_watermark() { + use iota_types::{ + base_types::ExecutionDigests, + digests::CheckpointContentsDigest, + effects::{TransactionEffectsAPI, TransactionEffectsExtForTesting}, + messages_checkpoint::CheckpointContentsExt, + }; + + let tmp_dir = iota_common::tempdir(); + let store = open_store(tmp_dir.path()); + + let effects = TransactionEffects::new_empty_v1_for_testing(TransactionDigest::random()); + let fx_digest = effects.digest(); + let tx_digest = *effects.transaction_digest(); + let contents = + CheckpointContents::new_with_digests_only_for_tests([ExecutionDigests::random()]); + let contents_digest = contents.digest(); + + store + .put_checkpoint_data( + 4, + CheckpointHistoryBatch { + effects: vec![(fx_digest, effects)], + executed_effects: vec![(tx_digest, fx_digest)], + events: vec![(tx_digest, TransactionEvents(vec![]))], + checkpoint_contents: vec![(contents_digest, contents)], + checkpoint_seq_by_contents: vec![(contents_digest, 42)], + checkpoint_range: Some((40, 45)), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!( + store.get_effects(&fx_digest).unwrap().map(|e| e.digest()), + Some(fx_digest) + ); + assert_eq!( + store.get_executed_effects(&tx_digest).unwrap(), + Some(fx_digest) + ); + assert!(store.get_events(&tx_digest).unwrap().is_some()); + assert!( + store + .get_checkpoint_contents(&contents_digest) + .unwrap() + .is_some() + ); + assert_eq!( + store + .get_checkpoint_seq_by_contents_digest(&contents_digest) + .unwrap(), + Some(42) + ); + assert_eq!(store.lowest_available_checkpoint().unwrap(), Some(40)); + + // An earlier batch of the same bucket lowers the watermark. + store + .put_checkpoint_data( + 4, + CheckpointHistoryBatch { + checkpoint_seq_by_contents: vec![(CheckpointContentsDigest::random(), 38)], + checkpoint_range: Some((38, 39)), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(store.lowest_available_checkpoint().unwrap(), Some(38)); + + // Everything survives a restart. + drop(store); + let store = open_store(tmp_dir.path()); + assert!(store.get_effects(&fx_digest).unwrap().is_some()); + assert_eq!(store.lowest_available_checkpoint().unwrap(), Some(38)); + + // Dropping the bucket clears the availability watermark. + store.drop_epoch(4).unwrap(); + assert_eq!(store.lowest_available_checkpoint().unwrap(), None); + assert!(store.get_effects(&fx_digest).unwrap().is_none()); + } + + #[tokio::test] + async fn checkpoint_db_copy_serves_all_families() { + use iota_types::{ + base_types::ExecutionDigests, messages_checkpoint::CheckpointContentsExt, + }; + + let tmp_dir = iota_common::tempdir(); + let store = open_store(tmp_dir.path()); + + let (object_key, object_row) = test_row(2); + store + .put_objects(3, &[(object_key, object_row)], &[]) + .unwrap(); + let contents = + CheckpointContents::new_with_digests_only_for_tests([ExecutionDigests::random()]); + let contents_digest = contents.digest(); + store + .put_checkpoint_data( + 3, + CheckpointHistoryBatch { + checkpoint_contents: vec![(contents_digest, contents)], + checkpoint_range: Some((7, 7)), + ..Default::default() + }, + ) + .unwrap(); + store.seal_epoch(3).unwrap(); + + // A snapshot of the shared database covers every epoch column family. + let copy_dir = iota_common::tempdir(); + let meta_handle = DBMap::::reopen( + &store.db, + Some(META_CF_NAME), + &ReadWriteOptions::default(), + true, + ) + .unwrap(); + let copy_path = copy_dir.path().join("db"); + meta_handle.checkpoint_db(©_path).unwrap(); + + let copy = open_store(©_path); + assert_eq!(copy.list_epochs(), vec![3]); + assert!(copy.is_sealed(3).unwrap()); + assert!(copy.get_object(&object_key).unwrap().is_some()); + assert!( + copy.get_checkpoint_contents(&contents_digest) + .unwrap() + .is_some() + ); + assert_eq!(copy.lowest_available_checkpoint().unwrap(), Some(7)); + + // The copy is independent of the source. + store.drop_epoch(3).unwrap(); + assert!(copy.get_object(&object_key).unwrap().is_some()); + } + + #[tokio::test] + async fn tombstone_rows_read_as_none_objects() { + let tmp_dir = iota_common::tempdir(); + let store = open_store(tmp_dir.path()); + let key = ObjectKey(ObjectId::random(), SequenceNumber::from_u64(4)); + store + .put_objects( + 2, + &[(key, StoreObjectWrapper::V2(StoreObject::Deleted))], + &[], + ) + .unwrap(); + assert!(store.get_store_object(&key).unwrap().is_some()); + assert!(store.get_object(&key).unwrap().is_none()); + } +} diff --git a/crates/iota-core/src/authority/test_authority_builder.rs b/crates/iota-core/src/authority/test_authority_builder.rs index 77fb470aca28..b24a04cc0816 100644 --- a/crates/iota-core/src/authority/test_authority_builder.rs +++ b/crates/iota-core/src/authority/test_authority_builder.rs @@ -30,12 +30,8 @@ use prometheus_filtered::Registry; use super::{backpressure::BackpressureManager, epoch_start_configuration::EpochFlag}; use crate::{ authority::{ - AuthorityState, AuthorityStore, - authority_per_epoch_store::AuthorityPerEpochStore, - authority_store_pruner::ObjectsCompactionFilter, - authority_store_tables::{ - AuthorityPerpetualTables, AuthorityPerpetualTablesOptions, AuthorityPrunerTables, - }, + AuthorityState, AuthorityStore, authority_per_epoch_store::AuthorityPerEpochStore, + authority_store_tables::AuthorityPerpetualTables, epoch_start_configuration::EpochStartConfiguration, }, checkpoints::CheckpointStore, @@ -225,29 +221,13 @@ impl<'a> TestAuthorityBuilder<'a> { .unwrap_or_else(|| iota_common::tempdir().keep()); let mut config = local_network_config.validator_configs()[0].clone(); let registry = Registry::new(); - let mut pruner_db = None; - if config - .authority_store_pruning_config - .enable_compaction_filter - { - pruner_db = Some(Arc::new(AuthorityPrunerTables::open( - &storage_dir.join("store"), - ))); - } - let compaction_filter = pruner_db - .clone() - .map(|db| ObjectsCompactionFilter::new(db, ®istry)); let authority_store = match self.store { Some(store) => store, None => { - let perpetual_tables_options = AuthorityPerpetualTablesOptions { - compaction_filter, - ..Default::default() - }; let perpetual_tables = Arc::new(AuthorityPerpetualTables::open( &storage_dir.join("store"), - Some(perpetual_tables_options), + None, )); // unwrap ok - for testing only. AuthorityStore::open_with_committee_for_testing( @@ -369,6 +349,7 @@ impl<'a> TestAuthorityBuilder<'a> { let policy_config = config.policy_config.clone(); let firewall_config = config.firewall_config.clone(); + let historic_store = authority_store.historic_store.clone(); let state = AuthorityState::new( name, secret, @@ -387,7 +368,7 @@ impl<'a> TestAuthorityBuilder<'a> { ArchiveReaderBalancer::default(), None, chain_identifier, - pruner_db, + historic_store, None, policy_config, firewall_config, diff --git a/crates/iota-core/src/checkpoints/checkpoint_executor/data_ingestion_handler.rs b/crates/iota-core/src/checkpoints/checkpoint_executor/data_ingestion_handler.rs index d6cb2fa49cd0..ca373a02cfdb 100644 --- a/crates/iota-core/src/checkpoints/checkpoint_executor/data_ingestion_handler.rs +++ b/crates/iota-core/src/checkpoints/checkpoint_executor/data_ingestion_handler.rs @@ -6,22 +6,71 @@ use std::{collections::HashMap, path::Path}; use iota_storage::blob::{Blob, BlobEncoding}; use iota_types::{ - effects::TransactionEffectsAPI, + effects::{TransactionEffectsAPI, TransactionEffectsExt}, error::{IotaError, IotaResult}, full_checkpoint_content::{CheckpointData, CheckpointTransaction}, - storage::ObjectStore, + object::Object, + storage::{ObjectKey, ObjectStore}, }; use crate::{ + authority::historic_store::HistoricStore, checkpoints::checkpoint_executor::{CheckpointExecutionData, CheckpointTransactionData}, execution_cache::TransactionCacheRead, }; +/// The input pre-images of `fx`, from the transaction's still-buffered +/// in-memory outputs when available (the common case: checkpoint data is +/// assembled before the outputs are committed), otherwise from the store, +/// with a final fallback to the historic epoch buckets for replay after a +/// restart, where the versions were already relocated. +fn transaction_input_objects( + fx: &iota_types::effects::TransactionEffects, + outputs: Option<&crate::transaction_outputs::TransactionOutputs>, + object_store: &dyn ObjectStore, + historic_store: &HistoricStore, +) -> IotaResult> { + let carried: HashMap = outputs + .map(|outputs| { + outputs + .superseded + .iter() + .map(|(key, object)| (*key, object)) + .collect() + }) + .unwrap_or_default(); + + fx.modified_at_versions() + .into_iter() + .map(|(object_id, version)| { + let key = ObjectKey(object_id, version); + if let Some(object) = carried.get(&key) { + return Ok((*object).clone()); + } + if let Some(object) = object_store + .try_get_object_by_key(&object_id, version) + .map_err(|e| IotaError::Unknown(e.to_string()))? + { + return Ok(object); + } + historic_store + .get_object(&key)? + .ok_or(IotaError::UserInput { + error: iota_types::error::UserInputError::ObjectNotFound { + object_id, + version: Some(version), + }, + }) + }) + .collect() +} + pub(crate) fn load_checkpoint_data( checkpoint_exec_data: &CheckpointExecutionData, checkpoint_tx_data: &CheckpointTransactionData, object_store: &dyn ObjectStore, transaction_cache_reader: &dyn TransactionCacheRead, + historic_store: &HistoricStore, ) -> IotaResult { let event_tx_digests = checkpoint_tx_data .effects @@ -53,10 +102,57 @@ pub(crate) fn load_checkpoint_data( .expect("event was already checked to be present") }); - let input_objects = iota_types::storage::get_transaction_input_objects(object_store, fx) - .map_err(|e| IotaError::Unknown(e.to_string()))?; - let output_objects = iota_types::storage::get_transaction_output_objects(object_store, fx) - .map_err(|e| IotaError::Unknown(e.to_string()))?; + let outputs = + transaction_cache_reader.try_get_pending_transaction_outputs(fx.transaction_digest()); + let input_objects = + transaction_input_objects(fx, outputs.as_deref(), object_store, historic_store)?; + let output_objects = match &outputs { + // Written objects are carried in the buffered outputs; no store + // lookups needed. + Some(outputs) => fx + .all_changed_objects() + .into_iter() + .map(|(object_ref, _, _)| { + outputs + .written + .get(&object_ref.object_id) + .filter(|object| object.version() == object_ref.version) + .cloned() + .ok_or(IotaError::UserInput { + error: iota_types::error::UserInputError::ObjectNotFound { + object_id: object_ref.object_id, + version: Some(object_ref.version), + }, + }) + }) + .collect::>>()?, + // Without buffered outputs (replay after restart, or a stage + // lagging behind later commits), read the store with a historic + // fallback: a later transaction may already have superseded an + // output here and relocated it (e.g. the clock output of an old + // checkpoint during catch-up). + None => fx + .all_changed_objects() + .into_iter() + .map(|(object_ref, _, _)| { + let key = ObjectKey::from(object_ref); + if let Some(object) = object_store + .try_get_object_by_key(&key.0, key.1) + .map_err(|e| IotaError::Unknown(e.to_string()))? + { + return Ok(object); + } + historic_store + .get_object(&key)? + .ok_or(IotaError::UserInput { + error: iota_types::error::UserInputError::ObjectNotFound { + object_id: key.0, + version: Some(key.1), + }, + }) + }) + .collect::>>()?, + }; let full_transaction = CheckpointTransaction { transaction: (*tx).clone().into_unsigned().into(), diff --git a/crates/iota-core/src/checkpoints/checkpoint_executor/mod.rs b/crates/iota-core/src/checkpoints/checkpoint_executor/mod.rs index e72e40540ee2..fd24bc72c140 100644 --- a/crates/iota-core/src/checkpoints/checkpoint_executor/mod.rs +++ b/crates/iota-core/src/checkpoints/checkpoint_executor/mod.rs @@ -267,20 +267,6 @@ impl CheckpointExecutor { let this = this.clone(); let pipeline_handle = pipeline_stages.handle(checkpoint.sequence_number()); async move { - // Leash: apply backpressure so the executed watermark cannot - // outrun the pruner unboundedly. Blocks here (before entering the - // pipeline) while pruning has fallen more than the slack behind - // its retention target; self-throttles execution under overload. - let executed_timestamp_ms = this - .checkpoint_store - .get_highest_executed_checkpoint() - .ok() - .flatten() - .map(|checkpoint| checkpoint.timestamp_ms) - .unwrap_or(0); - - this.state.pruner().await_leash(executed_timestamp_ms).await; - let pipeline_handle = pipeline_handle.await; tokio::spawn(this.execute_checkpoint(checkpoint, pipeline_handle)) .await @@ -683,6 +669,7 @@ impl CheckpointExecutor { tx_data, self.state.get_object_store(), &*self.transaction_cache_reader, + &self.state.historic_store, ) .expect("failed to load checkpoint data"); @@ -1027,6 +1014,7 @@ impl CheckpointExecutor { &tx_data, self.state.get_object_store(), self.transaction_cache_reader.as_ref(), + &self.state.historic_store, ) .expect("Failed to load full CheckpointData") }; diff --git a/crates/iota-core/src/checkpoints/epoch_info.rs b/crates/iota-core/src/checkpoints/epoch_info.rs index d8501f10deae..4f2fd3f65f27 100644 --- a/crates/iota-core/src/checkpoints/epoch_info.rs +++ b/crates/iota-core/src/checkpoints/epoch_info.rs @@ -469,7 +469,9 @@ fn assemble_boundary_checkpoint_data( contents: CheckpointContents, ) -> Result { use iota_types::{ - effects::TransactionEffectsAPI, full_checkpoint_content::CheckpointTransaction, + effects::{TransactionEffectsAPI, TransactionEffectsExt}, + full_checkpoint_content::CheckpointTransaction, + storage::ObjectKey, }; let inner = contents.transactions(); @@ -478,24 +480,74 @@ fn assemble_boundary_checkpoint_data( }; let tx_digest = boundary_digests.transaction; - let transaction = authority_store - .get_transaction_block(&tx_digest)? - .ok_or_else(|| StorageError::missing("missing boundary transaction"))?; - let effects = authority_store - .get_executed_effects(&tx_digest)? - .ok_or_else(|| StorageError::missing("missing boundary transaction effects"))?; - let output_objects = - iota_types::storage::get_transaction_output_objects(authority_store, &effects)?; + // Boundary data of old epochs has usually been relocated into the + // historic buckets by now (objects at commit time, the rest by the + // checkpoint pruner), so every read falls back to them after a live + // miss. `Missing` then only means the data aged past historic retention. + let transaction = match authority_store.get_transaction_block(&tx_digest)? { + Some(transaction) => transaction, + None => authority_store + .historic_store + .get_transaction(&tx_digest) + .map_err(StorageError::custom)? + .map(|transaction| transaction.into()) + .ok_or_else(|| StorageError::missing("missing boundary transaction"))?, + }; + let effects = match authority_store.get_executed_effects(&tx_digest)? { + Some(effects) => effects, + None => { + let effects_digest = authority_store + .historic_store + .get_executed_effects(&tx_digest) + .map_err(StorageError::custom)? + .ok_or_else(|| StorageError::missing("missing boundary transaction effects"))?; + authority_store + .historic_store + .get_effects(&effects_digest) + .map_err(StorageError::custom)? + .ok_or_else(|| StorageError::missing("missing boundary transaction effects"))? + } + }; + + let output_object_keys: Vec = effects + .all_changed_objects() + .into_iter() + .map(|(object_ref, _owner, _kind)| ObjectKey::from(object_ref)) + .collect(); + let output_objects = authority_store + .multi_get_objects_by_key(&output_object_keys) + .map_err(StorageError::custom)? + .into_iter() + .zip(&output_object_keys) + .map(|(maybe_object, key)| match maybe_object { + Some(object) => Ok(object), + None => authority_store + .historic_store + .get_object(key) + .map_err(StorageError::custom)? + .ok_or_else(|| { + StorageError::missing(format!( + "missing output object {key:?} of the boundary transaction" + )) + }), + }) + .collect::, StorageError>>()?; let events = if effects.events_digest().is_some() { - Some( - authority_store + let events = match authority_store + .get_events(effects.transaction_digest()) + .map_err(|e| StorageError::custom(format!("loading events: {e}")))? + { + Some(events) => events, + None => authority_store + .historic_store .get_events(effects.transaction_digest()) - .map_err(|e| StorageError::custom(format!("loading events: {e}")))? + .map_err(StorageError::custom)? .ok_or_else(|| { StorageError::missing("missing events for the boundary transaction") })?, - ) + }; + Some(events) } else { None }; diff --git a/crates/iota-core/src/db_checkpoint_handler.rs b/crates/iota-core/src/db_checkpoint_handler.rs index dbc7b04d788f..85ab558179ff 100644 --- a/crates/iota-core/src/db_checkpoint_handler.rs +++ b/crates/iota-core/src/db_checkpoint_handler.rs @@ -7,10 +7,7 @@ use std::{fs, num::NonZeroUsize, path::PathBuf, sync::Arc, time::Duration}; use anyhow::Result; use bytes::Bytes; use futures::future::try_join_all; -use iota_config::{ - node::AuthorityStorePruningConfig, - object_storage_config::{ObjectStoreConfig, ObjectStoreType}, -}; +use iota_config::object_storage_config::{ObjectStoreConfig, ObjectStoreType}; use iota_storage::object_store::util::{ copy_recursively, find_all_dirs_with_epoch_prefix, find_missing_epochs_dirs, path_to_filesystem, put, run_manifest_update_loop, write_snapshot_manifest, @@ -19,16 +16,8 @@ use object_store::{DynObjectStore, ObjectStoreExt, path::Path}; use prometheus_filtered::{IntGauge, Registry, register_int_gauge_with_registry}; use tracing::{debug, error, info}; -use crate::{ - authority::{ - authority_store_pruner::{ - AuthorityStorePruner, AuthorityStorePruningMetrics, EPOCH_DURATION_MS_FOR_TESTING, - }, - authority_store_tables::AuthorityPerpetualTables, - }, - checkpoint_progress_tracker::CheckpointProgressTracker, - checkpoints::CheckpointStore, - grpc_indexes::{GRPC_INDEXES_DIR, GrpcIndexesStore}, +use crate::authority::{ + authority_store_pruner::AuthorityStorePruner, authority_store_tables::AuthorityPerpetualTables, }; pub const SUCCESS_MARKER: &str = "_SUCCESS"; @@ -78,10 +67,7 @@ pub struct DBCheckpointHandler { prune_and_compact_before_upload: bool, /// If true, upload will block on state snapshot upload completed marker state_snapshot_enabled: bool, - /// Pruning objects - pruning_config: AuthorityStorePruningConfig, metrics: Arc, - checkpoint_progress_tracker: Option>, } impl DBCheckpointHandler { @@ -90,10 +76,8 @@ impl DBCheckpointHandler { output_object_store_config: Option<&ObjectStoreConfig>, interval_s: u64, prune_and_compact_before_upload: bool, - pruning_config: AuthorityStorePruningConfig, registry: &Registry, state_snapshot_enabled: bool, - checkpoint_progress_tracker: Option>, ) -> Result> { let input_store_config = ObjectStoreConfig { object_store: Some(ObjectStoreType::File), @@ -113,9 +97,7 @@ impl DBCheckpointHandler { gc_markers, prune_and_compact_before_upload, state_snapshot_enabled, - pruning_config, metrics: DBCheckpointMetrics::new(registry), - checkpoint_progress_tracker, })) } pub fn new_for_test( @@ -138,9 +120,7 @@ impl DBCheckpointHandler { gc_markers: vec![UPLOAD_COMPLETED_MARKER.to_string(), TEST_MARKER.to_string()], prune_and_compact_before_upload, state_snapshot_enabled, - pruning_config: AuthorityStorePruningConfig::default(), metrics: DBCheckpointMetrics::new(&Registry::default()), - checkpoint_progress_tracker: None, })) } @@ -271,33 +251,11 @@ impl DBCheckpointHandler { Ok(()) } - async fn prune_and_compact( - &self, - db_path: PathBuf, - epoch: u64, - epoch_duration_ms: u64, - ) -> Result<()> { + async fn compact(&self, db_path: PathBuf, epoch: u64) -> Result<()> { + // The source node prunes continuously by relocation, so there is + // nothing to prune in a DB checkpoint before upload; compacting + // reclaims the space of any not-yet-compacted deletes. let perpetual_db = Arc::new(AuthorityPerpetualTables::open(&db_path.join("store"), None)); - let checkpoint_store = Arc::new(CheckpointStore::new_for_db_checkpoint_handler( - &db_path.join("checkpoints"), - )); - let grpc_indexes_store = GrpcIndexesStore::new_without_init(db_path.join(GRPC_INDEXES_DIR)); - let metrics = AuthorityStorePruningMetrics::new(&Registry::default()); - info!( - "Pruning db checkpoint in {:?} for epoch: {epoch}", - db_path.display() - ); - AuthorityStorePruner::prune_objects_for_eligible_epochs( - &perpetual_db, - &checkpoint_store, - Some(&grpc_indexes_store), - None, - self.pruning_config.clone(), - metrics, - epoch_duration_ms, - self.checkpoint_progress_tracker.as_ref(), - ) - .await?; info!( "Compacting db checkpoint in {:?} for epoch: {epoch}", db_path.display() @@ -341,9 +299,7 @@ impl DBCheckpointHandler { } if self.prune_and_compact_before_upload { - // Invoke pruning and compaction on the db checkpoint - self.prune_and_compact(local_db_path, *epoch, EPOCH_DURATION_MS_FOR_TESTING) - .await?; + self.compact(local_db_path, *epoch).await?; } info!("Copying db checkpoint for epoch: {epoch} to remote storage"); diff --git a/crates/iota-core/src/execution_cache.rs b/crates/iota-core/src/execution_cache.rs index db386b90f0fc..d32ddc0bbb6f 100644 --- a/crates/iota-core/src/execution_cache.rs +++ b/crates/iota-core/src/execution_cache.rs @@ -783,6 +783,18 @@ pub trait ObjectCacheRead: Send + Sync { } pub trait TransactionCacheRead: Send + Sync { + /// The in-memory outputs of an executed transaction that has not been + /// committed to disk yet, if still buffered. Lets checkpoint-data + /// assembly serve input pre-images and written objects from memory + /// instead of per-object store lookups; callers must handle `None` (e.g. + /// replay after a restart) with a store read. + fn try_get_pending_transaction_outputs( + &self, + _digest: &TransactionDigest, + ) -> Option> { + None + } + fn try_multi_get_transaction_blocks( &self, digests: &[TransactionDigest], diff --git a/crates/iota-core/src/execution_cache/unit_tests/writeback_cache_tests.rs b/crates/iota-core/src/execution_cache/unit_tests/writeback_cache_tests.rs index bef1bd4b5412..8aba22ac631a 100644 --- a/crates/iota-core/src/execution_cache/unit_tests/writeback_cache_tests.rs +++ b/crates/iota-core/src/execution_cache/unit_tests/writeback_cache_tests.rs @@ -181,6 +181,7 @@ impl Scenario { live_object_markers_to_delete: Default::default(), new_live_object_markers_to_init: Default::default(), written: Default::default(), + superseded: Default::default(), } } diff --git a/crates/iota-core/src/execution_cache/writeback_cache.rs b/crates/iota-core/src/execution_cache/writeback_cache.rs index 609de324851e..f7de0f0a3570 100644 --- a/crates/iota-core/src/execution_cache/writeback_cache.rs +++ b/crates/iota-core/src/execution_cache/writeback_cache.rs @@ -1866,6 +1866,16 @@ impl ObjectCacheRead for WritebackCache { } impl TransactionCacheRead for WritebackCache { + fn try_get_pending_transaction_outputs( + &self, + digest: &TransactionDigest, + ) -> Option> { + self.dirty + .pending_transaction_writes + .get(digest) + .map(|entry| entry.value().clone()) + } + #[instrument(level = "trace", skip_all)] fn try_multi_get_transaction_blocks( &self, diff --git a/crates/iota-core/src/storage.rs b/crates/iota-core/src/storage.rs index 57375e05a540..6fda98731ae5 100644 --- a/crates/iota-core/src/storage.rs +++ b/crates/iota-core/src/storage.rs @@ -9,6 +9,7 @@ use iota_sdk_types::StructTag; use iota_types::{ base_types::TransactionDigest, committee::{Committee, EpochId}, + digests::TransactionEffectsDigest, effects::{TransactionEffects, TransactionEvents}, error::IotaError, messages_checkpoint::{ @@ -27,8 +28,10 @@ use parking_lot::Mutex; use tracing::instrument; use crate::{ - authority::AuthorityState, checkpoints::CheckpointStore, - epoch::committee_store::CommitteeStore, execution_cache::ExecutionCacheTraitPointers, + authority::{AuthorityState, historic_store::HistoricStore}, + checkpoints::CheckpointStore, + epoch::committee_store::CommitteeStore, + execution_cache::ExecutionCacheTraitPointers, grpc_indexes::GrpcIndexesStore, }; @@ -38,6 +41,11 @@ pub struct RocksDbStore { committee_store: Arc, checkpoint_store: Arc, + /// Relocated checkpoint-keyed history (transactions, effects, events, + /// checkpoint contents and summaries). Consulted only after a live-table + /// miss, so recent reads never touch it; extends the availability + /// horizon served to gRPC and state-sync peers. + historic_store: Arc, // in memory checkpoint watermark sequence numbers highest_verified_checkpoint: Arc>>, highest_synced_checkpoint: Arc>>, @@ -48,16 +56,36 @@ impl RocksDbStore { cache_traits: ExecutionCacheTraitPointers, committee_store: Arc, checkpoint_store: Arc, + historic_store: Arc, ) -> Self { Self { cache_traits, committee_store, checkpoint_store, + historic_store, highest_verified_checkpoint: Arc::new(Mutex::new(None)), highest_synced_checkpoint: Arc::new(Mutex::new(None)), } } + /// Effects by effects digest, falling back to relocated history. + fn get_effects_with_historic_fallback( + &self, + digest: &TransactionEffectsDigest, + ) -> Result, StorageError> { + if let Some(effects) = self + .cache_traits + .transaction_cache_reader + .try_get_effects(digest) + .map_err(StorageError::custom)? + { + return Ok(Some(effects)); + } + self.historic_store + .get_effects(digest) + .map_err(StorageError::custom) + } + pub fn get_objects(&self, object_keys: &[ObjectKey]) -> Result>, IotaError> { self.cache_traits .object_cache_reader @@ -74,9 +102,18 @@ impl ReadStore for RocksDbStore { &self, digest: &CheckpointDigest, ) -> Result, StorageError> { - self.checkpoint_store + if let Some(checkpoint) = self + .checkpoint_store .get_checkpoint_by_digest(digest) - .map_err(Into::into) + .map_err(Into::::into)? + { + return Ok(Some(checkpoint)); + } + Ok(self + .historic_store + .get_checkpoint_by_digest(digest) + .map_err(StorageError::custom)? + .map(Into::into)) } fn try_get_checkpoint_by_sequence_number( @@ -111,25 +148,45 @@ impl ReadStore for RocksDbStore { fn try_get_lowest_available_checkpoint( &self, ) -> Result { - if let Some(highest_pruned_cp) = self + let after_pruned = if let Some(highest_pruned_cp) = self .checkpoint_store .get_highest_pruned_checkpoint_seq_number() .map_err(Into::::into)? { - Ok(highest_pruned_cp + 1) + highest_pruned_cp + 1 } else { - Ok(0) - } + 0 + }; + // Relocated history extends availability below the pruning + // watermark; coverage is contiguous because relocation processes + // checkpoints in order and buckets expire oldest-first. + let Some(historic_lowest) = self + .historic_store + .lowest_available_checkpoint() + .map_err(StorageError::custom)? + else { + return Ok(after_pruned); + }; + Ok(historic_lowest.min(after_pruned)) } fn try_get_full_checkpoint_contents_by_sequence_number( &self, sequence_number: CheckpointSequenceNumber, ) -> Result, StorageError> { - Ok(self + if let Some(contents) = self .checkpoint_store .get_full_checkpoint_contents_by_sequence_number(sequence_number) - .map(|contents| contents.as_ref().clone())) + { + return Ok(Some(contents.as_ref().clone())); + } + // The full-checkpoint-contents cache only holds recent checkpoints; + // older ones are assembled from their components (with historic + // fallbacks) via the summary, which is never pruned. + match self.try_get_checkpoint_by_sequence_number(sequence_number)? { + Some(checkpoint) => self.try_get_full_checkpoint_contents(&checkpoint.content_digest), + None => Ok(None), + } } fn try_get_full_checkpoint_contents( @@ -145,19 +202,15 @@ impl ReadStore for RocksDbStore { return Ok(Some(contents.as_ref().clone())); } - // Otherwise gather it from the individual components. - self.checkpoint_store - .get_checkpoint_contents(digest) - .map_err(iota_types::storage::error::Error::custom)? + // Otherwise gather it from the individual components, each falling + // back to relocated history for old checkpoints. + self.try_get_checkpoint_contents_by_digest(digest)? .map(|contents| { let mut transactions = Vec::with_capacity(contents.len()); for tx in contents.iter() { if let (Some(t), Some(e)) = ( self.try_get_transaction(&tx.transaction)?, - self.cache_traits - .transaction_cache_reader - .try_get_effects(&tx.effects) - .map_err(iota_types::storage::error::Error::custom)?, + self.get_effects_with_historic_fallback(&tx.effects)?, ) { transactions.push(iota_types::base_types::ExecutionData::new( (*t).clone().into_inner(), @@ -193,19 +246,42 @@ impl ReadStore for RocksDbStore { &self, digest: &TransactionDigest, ) -> Result>, StorageError> { - self.cache_traits + if let Some(transaction) = self + .cache_traits .transaction_cache_reader .try_get_transaction_block(digest) - .map_err(StorageError::custom) + .map_err(StorageError::custom)? + { + return Ok(Some(transaction)); + } + Ok(self + .historic_store + .get_transaction(digest) + .map_err(StorageError::custom)? + .map(|transaction| Arc::new(transaction.into()))) } fn try_get_transaction_effects( &self, digest: &TransactionDigest, ) -> Result, StorageError> { - self.cache_traits + if let Some(effects) = self + .cache_traits .transaction_cache_reader .try_get_executed_effects(digest) + .map_err(StorageError::custom)? + { + return Ok(Some(effects)); + } + let Some(effects_digest) = self + .historic_store + .get_executed_effects(digest) + .map_err(StorageError::custom)? + else { + return Ok(None); + }; + self.historic_store + .get_effects(&effects_digest) .map_err(StorageError::custom) } @@ -213,9 +289,16 @@ impl ReadStore for RocksDbStore { &self, digest: &TransactionDigest, ) -> Result, StorageError> { - self.cache_traits + if let Some(events) = self + .cache_traits .transaction_cache_reader .try_get_events(digest) + .map_err(StorageError::custom)? + { + return Ok(Some(events)); + } + self.historic_store + .get_events(digest) .map_err(StorageError::custom) } @@ -234,9 +317,16 @@ impl ReadStore for RocksDbStore { ) -> iota_types::storage::error::Result< Option, > { - self.checkpoint_store + if let Some(contents) = self + .checkpoint_store .get_checkpoint_contents(digest) - .map_err(iota_types::storage::error::Error::custom) + .map_err(iota_types::storage::error::Error::custom)? + { + return Ok(Some(contents)); + } + self.historic_store + .get_checkpoint_contents(digest) + .map_err(StorageError::custom) } fn try_get_checkpoint_contents_by_sequence_number( @@ -382,7 +472,17 @@ impl ObjectStore for GrpcReadStore { object_id: &iota_sdk_types::ObjectId, version: iota_types::base_types::VersionNumber, ) -> iota_types::storage::error::Result> { - self.rocks.try_get_object_by_key(object_id, version) + if let Some(object) = self.rocks.try_get_object_by_key(object_id, version)? { + return Ok(Some(object)); + } + // Fall back to relocated (superseded) versions. `GrpcReadStore` is + // constructed only for the gRPC server, so this fallback is + // unreachable from consensus and execution: a live-table miss there + // must stay a miss. + self.state + .historic_store + .get_object(&ObjectKey(*object_id, version)) + .map_err(StorageError::custom) } } @@ -491,13 +591,28 @@ impl GrpcStateReader for GrpcReadStore { fn get_lowest_available_checkpoint_objects( &self, ) -> iota_types::storage::error::Result { - Ok(self + let after_pruned = self .state .get_object_cache_reader() .try_get_highest_pruned_checkpoint() .map_err(StorageError::custom)? .map(|cp| cp + 1) - .unwrap_or(0)) + .unwrap_or(0); + // Exact-version availability extends back to the start of the + // earliest retained epoch bucket. + let Some(earliest_epoch) = self.state.historic_store.earliest_epoch() else { + return Ok(after_pruned); + }; + let historic_start = if earliest_epoch == 0 { + Some(0) + } else { + self.rocks + .checkpoint_store + .get_epoch_last_checkpoint_seq_number(earliest_epoch - 1) + .map_err(StorageError::custom)? + .map(|seq| seq + 1) + }; + Ok(historic_start.unwrap_or(after_pruned).min(after_pruned)) } fn get_chain_identifier(&self) -> Result { diff --git a/crates/iota-core/src/transaction_outputs.rs b/crates/iota-core/src/transaction_outputs.rs index 275e553ba1d1..00304caaa740 100644 --- a/crates/iota-core/src/transaction_outputs.rs +++ b/crates/iota-core/src/transaction_outputs.rs @@ -13,6 +13,7 @@ use iota_types::{ TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt, TransactionEvents, }, inner_temporary_store::{InnerTemporaryStore, WrittenObjects}, + object::Object, storage::{MarkerValue, ObjectKey}, transaction::{TransactionDataAPI, VerifiedTransaction}, }; @@ -29,6 +30,13 @@ pub struct TransactionOutputs { pub live_object_markers_to_delete: Vec, pub new_live_object_markers_to_init: Vec, pub written: WrittenObjects, + /// Pre-images of the input versions this transaction superseded + /// (`effects.modified_at_versions()`), carried in memory so checkpoint + /// commit can relocate them into the historic epoch bucket in the same + /// atomic batch, without reading them back from the store. Best-effort + /// and only captured when the historic store is enabled: anything not + /// captured here is relocated by the pruner later. + pub superseded: Vec<(ObjectKey, Object)>, } impl TransactionOutputs { @@ -128,6 +136,21 @@ impl TransactionOutputs { let wrapped = effects.wrapped().into_iter().map(ObjectKey::from).collect(); + // The pre-images of superseded *input* versions: the mutated, + // deleted, wrapped and received inputs at their input version. + // Mutations of runtime-loaded objects (dynamic fields) are not + // inputs and are completed from the object cache in + // `commit_transaction`. + let superseded = modified_at + .iter() + .filter_map(|(id, version)| { + input_objects + .get(id) + .filter(|object| object.version() == *version) + .map(|object| (ObjectKey(*id, *version), object.clone())) + }) + .collect(); + TransactionOutputs { transaction: Arc::new(transaction), effects, @@ -138,6 +161,7 @@ impl TransactionOutputs { live_object_markers_to_delete, new_live_object_markers_to_init, written, + superseded, } } } diff --git a/crates/iota-e2e-tests/tests/abstract_account_tests.rs b/crates/iota-e2e-tests/tests/abstract_account_tests.rs index 54fbf1b48573..0ecd62504af6 100644 --- a/crates/iota-e2e-tests/tests/abstract_account_tests.rs +++ b/crates/iota-e2e-tests/tests/abstract_account_tests.rs @@ -2331,7 +2331,7 @@ impl TestEnvironment { // The transaction must be successful assert!(confirmed_local_execution.unwrap()); - assert!(errors.is_empty()); + assert!(errors.is_empty(), "response errors: {errors:?}"); Ok(()) } diff --git a/crates/iota-e2e-tests/tests/object_deletion_tests.rs b/crates/iota-e2e-tests/tests/object_deletion_tests.rs index 3f66b8b836d0..f36399d1a1bd 100644 --- a/crates/iota-e2e-tests/tests/object_deletion_tests.rs +++ b/crates/iota-e2e-tests/tests/object_deletion_tests.rs @@ -63,10 +63,12 @@ mod sim_only_tests { .prune_objects_and_compact_for_testing(checkpoint_store, None) .await; - // Check that no object with `child_id` exists in object store. + // The child's superseded versions were relocated into the + // historic buckets; only its `Wrapped` tombstone stays in the + // live table as the lineage head. assert_eq!( state.database_for_testing().count_object_versions(child_id), - 0 + 1 ); assert!( state @@ -119,16 +121,21 @@ mod sim_only_tests { .prune_objects_and_compact_for_testing(checkpoint_store, None) .await; - // Check that both root and child objects are gone from object store. + // Both lineages end in a `Deleted` tombstone, which stays in + // the live table as the head until its epoch bucket expires; + // everything below it was relocated. The child also keeps the + // stale `Wrapped` tombstone from before its resurrection: it + // is only removed by the exact-key delete when the wrapping + // epoch's bucket expires. assert_eq!( state.database_for_testing().count_object_versions(child_id), - 0 + 2 ); assert_eq!( state .database_for_testing() .count_object_versions(object_id), - 0 + 1 ); }) .await; diff --git a/crates/iota-node/src/lib.rs b/crates/iota-node/src/lib.rs index 159ef8443a34..f97f05ffa032 100644 --- a/crates/iota-node/src/lib.rs +++ b/crates/iota-node/src/lib.rs @@ -34,12 +34,10 @@ use iota_core::{ authority::{ AuthorityState, AuthorityStore, RandomnessRoundReceiver, authority_per_epoch_store::AuthorityPerEpochStore, - authority_store_pruner::ObjectsCompactionFilter, - authority_store_tables::{ - AuthorityPerpetualTables, AuthorityPerpetualTablesOptions, AuthorityPrunerTables, - }, + authority_store_tables::{AuthorityPerpetualTables, AuthorityPerpetualTablesOptions}, backpressure::BackpressureManager, epoch_start_configuration::{EpochFlag, EpochStartConfigTrait, EpochStartConfiguration}, + historic_store::{HistoricStore, HistoricStoreMetrics}, }, authority_aggregator::{ AggregatorSendCapabilityNotificationError, AuthAggMetrics, AuthorityAggregator, @@ -422,29 +420,26 @@ impl IotaNode { None, )); - let mut pruner_db = None; - if config - .authority_store_pruning_config - .enable_compaction_filter - { - pruner_db = Some(Arc::new(AuthorityPrunerTables::open( - &config.db_path().join("store"), - ))); - } - let compaction_filter = pruner_db - .clone() - .map(|db| ObjectsCompactionFilter::new(db, &prometheus_registry)); - // By default, only enable write stall on validators for perpetual db. let enable_write_stall = config.enable_db_write_stall.unwrap_or(is_validator); + // The historic epoch buckets are column families of the perpetual + // database; buckets already on disk must be reopened with their tuned + // options. + let extra_column_families = HistoricStore::extra_column_family_options( + &AuthorityPerpetualTables::path(&config.db_path().join("store")), + ); let perpetual_tables_options = AuthorityPerpetualTablesOptions { enable_write_stall, - compaction_filter, + extra_column_families, }; let perpetual_tables = Arc::new(AuthorityPerpetualTables::open( &config.db_path().join("store"), Some(perpetual_tables_options), )); + let historic_store = Arc::new(HistoricStore::new_shared( + perpetual_tables.database(), + HistoricStoreMetrics::new(&prometheus_registry), + )?); let is_genesis = perpetual_tables .database_is_empty() .expect("Database read should not fail at init."); @@ -467,6 +462,7 @@ impl IotaNode { &config, &prometheus_registry, migration_tx_data.as_ref(), + historic_store.clone(), ) .await?; @@ -571,6 +567,7 @@ impl IotaNode { cache_traits.clone(), committee_store.clone(), checkpoint_store.clone(), + historic_store.clone(), ); let index_store = if is_full_node && config.enable_index_processing { @@ -664,7 +661,6 @@ impl IotaNode { &config, &prometheus_registry, state_snapshot_handle.is_some(), - Some(checkpoint_progress_tracker.clone()), )?; let mut genesis_objects = genesis.objects().to_vec(); @@ -701,7 +697,7 @@ impl IotaNode { archive_readers, validator_tx_finalizer, chain_identifier, - pruner_db, + historic_store, Some(checkpoint_progress_tracker.clone()), config.policy_config.clone(), config.firewall_config.clone(), @@ -1146,7 +1142,6 @@ impl IotaNode { config: &NodeConfig, prometheus_registry: &Registry, state_snapshot_enabled: bool, - checkpoint_progress_tracker: Option>, ) -> Result<( DBCheckpointConfig, Option>, @@ -1191,10 +1186,8 @@ impl IotaNode { db_checkpoint_config .prune_and_compact_before_upload .unwrap_or(true), - config.authority_store_pruning_config.clone(), prometheus_registry, state_snapshot_enabled, - checkpoint_progress_tracker, )?; Ok(( db_checkpoint_config, diff --git a/crates/iota-proxy/README.md b/crates/iota-proxy/README.md index 779ceccbc163..39333030c77a 100644 --- a/crates/iota-proxy/README.md +++ b/crates/iota-proxy/README.md @@ -166,7 +166,6 @@ migration-tx-data-path: /opt/iota/config/migration.blob # Pruning configuration authority-store-pruning-config: num-latest-epoch-dbs-to-retain: 3 - num-epochs-to-retain: 0 num-epochs-to-retain-for-checkpoints: 2 periodic-compaction-threshold-days: 1 diff --git a/crates/iota-swarm-config/src/node_config_builder.rs b/crates/iota-swarm-config/src/node_config_builder.rs index 7c8f0b75a216..62d5c7592519 100644 --- a/crates/iota-swarm-config/src/node_config_builder.rs +++ b/crates/iota-swarm-config/src/node_config_builder.rs @@ -528,7 +528,6 @@ impl FullnodeConfigBuilder { let mut pruning_config = AuthorityStorePruningConfig::default(); if self.disable_pruning { pruning_config.set_num_epochs_to_retain_for_checkpoints(None); - pruning_config.set_num_epochs_to_retain(u64::MAX); }; NodeConfig { diff --git a/crates/iota-swarm-config/tests/snapshots/snapshot_tests__network_config_snapshot_matches.snap b/crates/iota-swarm-config/tests/snapshots/snapshot_tests__network_config_snapshot_matches.snap index 7e5b8ea858d7..e54e22a8f195 100644 --- a/crates/iota-swarm-config/tests/snapshots/snapshot_tests__network_config_snapshot_matches.snap +++ b/crates/iota-swarm-config/tests/snapshots/snapshot_tests__network_config_snapshot_matches.snap @@ -36,7 +36,7 @@ validator_configs: migration-tx-data-path: "[fake migration path]" authority-store-pruning-config: num-latest-epoch-dbs-to-retain: 3 - num-epochs-to-retain: 0 + historic-epochs-to-retain: 2 end-of-epoch-broadcast-channel-capacity: 128 checkpoint-executor-config: checkpoint-execution-max-concurrency: 4 @@ -135,7 +135,7 @@ validator_configs: migration-tx-data-path: "[fake migration path]" authority-store-pruning-config: num-latest-epoch-dbs-to-retain: 3 - num-epochs-to-retain: 0 + historic-epochs-to-retain: 2 end-of-epoch-broadcast-channel-capacity: 128 checkpoint-executor-config: checkpoint-execution-max-concurrency: 4 @@ -234,7 +234,7 @@ validator_configs: migration-tx-data-path: "[fake migration path]" authority-store-pruning-config: num-latest-epoch-dbs-to-retain: 3 - num-epochs-to-retain: 0 + historic-epochs-to-retain: 2 end-of-epoch-broadcast-channel-capacity: 128 checkpoint-executor-config: checkpoint-execution-max-concurrency: 4 @@ -333,7 +333,7 @@ validator_configs: migration-tx-data-path: "[fake migration path]" authority-store-pruning-config: num-latest-epoch-dbs-to-retain: 3 - num-epochs-to-retain: 0 + historic-epochs-to-retain: 2 end-of-epoch-broadcast-channel-capacity: 128 checkpoint-executor-config: checkpoint-execution-max-concurrency: 4 @@ -432,7 +432,7 @@ validator_configs: migration-tx-data-path: "[fake migration path]" authority-store-pruning-config: num-latest-epoch-dbs-to-retain: 3 - num-epochs-to-retain: 0 + historic-epochs-to-retain: 2 end-of-epoch-broadcast-channel-capacity: 128 checkpoint-executor-config: checkpoint-execution-max-concurrency: 4 @@ -531,7 +531,7 @@ validator_configs: migration-tx-data-path: "[fake migration path]" authority-store-pruning-config: num-latest-epoch-dbs-to-retain: 3 - num-epochs-to-retain: 0 + historic-epochs-to-retain: 2 end-of-epoch-broadcast-channel-capacity: 128 checkpoint-executor-config: checkpoint-execution-max-concurrency: 4 @@ -630,7 +630,7 @@ validator_configs: migration-tx-data-path: "[fake migration path]" authority-store-pruning-config: num-latest-epoch-dbs-to-retain: 3 - num-epochs-to-retain: 0 + historic-epochs-to-retain: 2 end-of-epoch-broadcast-channel-capacity: 128 checkpoint-executor-config: checkpoint-execution-max-concurrency: 4 diff --git a/crates/iota-tool/src/db_tool/db_dump.rs b/crates/iota-tool/src/db_tool/db_dump.rs index d1fd663b0b8a..40bc72a22b59 100644 --- a/crates/iota-tool/src/db_tool/db_dump.rs +++ b/crates/iota-tool/src/db_tool/db_dump.rs @@ -22,6 +22,7 @@ use iota_core::{ }, authority_store_tables::AuthorityPerpetualTables, authority_store_types::{StoreData, StoreObject}, + historic_store::{HistoricStore, HistoricStoreMetrics}, }, checkpoints::CheckpointStore, epoch::committee_store::CommitteeStoreTables, @@ -219,19 +220,19 @@ pub async fn prune_objects(db_path: PathBuf) -> anyhow::Result<()> { info!("Highest pruned checkpoint: {}", highest_pruned_checkpoint); let metrics = AuthorityStorePruningMetrics::new(&Registry::default()); info!("Pruning setup for db at path: {:?}", db_path.display()); - let pruning_config = AuthorityStorePruningConfig { - num_epochs_to_retain: 0, - ..Default::default() - }; + let pruning_config = AuthorityStorePruningConfig::default(); + let historic_store = Arc::new(HistoricStore::new_shared( + perpetual_db.database(), + HistoricStoreMetrics::new(&Registry::default()), + )?); info!("Starting object pruning"); AuthorityStorePruner::prune_objects_for_eligible_epochs( &perpetual_db, &checkpoint_store, Some(&grpc_indexes_store), - None, - pruning_config, + &historic_store, + &pruning_config, metrics, - EPOCH_DURATION_MS_FOR_TESTING, None, ) .await?; @@ -248,14 +249,18 @@ pub async fn prune_checkpoints(db_path: PathBuf) -> anyhow::Result<()> { num_epochs_to_retain_for_checkpoints: Some(1), ..Default::default() }; + let historic_store = Arc::new(HistoricStore::new_shared( + perpetual_db.database(), + HistoricStoreMetrics::new(&Registry::default()), + )?); info!("Starting txns and effects pruning"); let archive_readers = ArchiveReaderBalancer::default(); AuthorityStorePruner::prune_checkpoints_for_eligible_epochs( &perpetual_db, &checkpoint_store, Some(&grpc_indexes_store), - None, - pruning_config, + &historic_store, + &pruning_config, metrics, archive_readers, EPOCH_DURATION_MS_FOR_TESTING, diff --git a/crates/iota-tool/src/lib.rs b/crates/iota-tool/src/lib.rs index 2ae3f463039c..d2b5661bad8d 100644 --- a/crates/iota-tool/src/lib.rs +++ b/crates/iota-tool/src/lib.rs @@ -37,7 +37,11 @@ use iota_config::{ object_storage_config::{ObjectStoreConfig, ObjectStoreType}, }; use iota_core::{ - authority::{AuthorityStore, authority_store_tables::AuthorityPerpetualTables}, + authority::{ + AuthorityStore, + authority_store_tables::AuthorityPerpetualTables, + historic_store::{HistoricStore, HistoricStoreMetrics}, + }, authority_client::{NetworkAuthorityClient, validator::ValidatorAPI}, checkpoints::CheckpointStore, epoch::committee_store::CommitteeStore, @@ -642,10 +646,23 @@ pub async fn backfill_checkpoint_summaries( )); let committee_store = Arc::new(CommitteeStore::open(node_db_path.join("epochs"), None)?); let checkpoint_store = CheckpointStore::new(&node_db_path.join("checkpoints")); - let store = AuthorityStore::open_no_genesis(perpetual_db, false, &Registry::default())?; + let historic_store = Arc::new(HistoricStore::new_shared( + perpetual_db.database(), + HistoricStoreMetrics::new(&Registry::default()), + )?); + let store = AuthorityStore::open_no_genesis( + perpetual_db, + false, + &Registry::default(), + historic_store.clone(), + )?; let cache_traits = build_execution_cache_from_env(&Registry::default(), &store); - let state_sync_store = - RocksDbStore::new(cache_traits, committee_store, checkpoint_store.clone()); + let state_sync_store = RocksDbStore::new( + cache_traits, + committee_store, + checkpoint_store.clone(), + historic_store, + ); let highest_synced = checkpoint_store .get_highest_synced_checkpoint()? @@ -1098,8 +1115,16 @@ pub async fn download_formal_snapshot( .restore_epoch_info(&*checkpoint_store) .await?; - let authority_store = - AuthorityStore::open_no_genesis(perpetual_db.clone(), false, &Registry::default())?; + let historic_store = Arc::new(HistoricStore::new_shared( + perpetual_db.database(), + HistoricStoreMetrics::new(&Registry::default()), + )?); + let authority_store = AuthorityStore::open_no_genesis( + perpetual_db.clone(), + false, + &Registry::default(), + historic_store, + )?; checkpoint_store.ensure_current_epoch_info(&authority_store)?; // Finalize the gRPC live-state index store so the node opens it in place diff --git a/crates/typed-store-derive/src/lib.rs b/crates/typed-store-derive/src/lib.rs index 61cb44c4e6d7..9a8e9c890d03 100644 --- a/crates/typed-store-derive/src/lib.rs +++ b/crates/typed-store-derive/src/lib.rs @@ -380,16 +380,34 @@ pub fn derive_dbmap_utils_general(input: TokenStream) -> TokenStream { }; let (db, rwopt_cfs) = { let opt_cfs = match tables_db_options_override { - None => [ + None => vec![ #( (stringify!(#active_cf_names).to_owned(), #active_default_options_override_fn_names()), )* ], - Some(o) => [ - #( - (stringify!(#active_cf_names).to_owned(), o.to_map().get(stringify!(#active_cf_names)).unwrap_or(&default_cf_opt).clone()), - )* - ] + Some(o) => { + let mut opt_cfs = vec![ + #( + (stringify!(#active_cf_names).to_owned(), o.to_map().get(stringify!(#active_cf_names)).unwrap_or(&default_cf_opt).clone()), + )* + ]; + // Config-map entries that do not correspond to a + // struct field are opened as additional column + // families with the given options; without this, + // dynamically created column families would be + // rediscovered with default options. + let struct_fields: std::collections::HashSet<&'static str> = [ + #( + stringify!(#active_cf_names), + )* + ].into_iter().collect(); + for (name, options) in o.to_map() { + if !struct_fields.contains(name.as_str()) { + opt_cfs.push((name, options)); + } + } + opt_cfs + } }; // Safe to call unwrap because we will have at least one field_name entry in the struct let rwopt_cfs: std::collections::HashMap = opt_cfs.iter().map(|q| (q.0.as_str().to_string(), q.1.rw_options.clone())).collect(); diff --git a/crates/typed-store/src/database.rs b/crates/typed-store/src/database.rs index 95e7fad86553..44c8b75b602c 100644 --- a/crates/typed-store/src/database.rs +++ b/crates/typed-store/src/database.rs @@ -227,9 +227,48 @@ impl Database { } } + /// Names of all currently open column families, including ones created + /// at runtime. + pub fn column_family_names(&self) -> Vec { + match &self.storage { + Storage::Rocks(db) => db + .cf_names + .read() + .expect("lock should not be poisoned") + .clone(), + Storage::InMemory(_) => Vec::new(), + } + } + + /// Creates a new column family at runtime. Fails if a column family with + /// this name already exists. + pub fn create_cf(&self, name: &str, options: &rocksdb::Options) -> Result<(), rocksdb::Error> { + match &self.storage { + Storage::Rocks(db) => { + db.underlying.create_cf(name, options)?; + let mut cf_names = db.cf_names.write().expect("lock should not be poisoned"); + if !cf_names.iter().any(|cf| cf == name) { + cf_names.push(name.to_string()); + } + Ok(()) + } + Storage::InMemory(db) => { + db.create_cf(name); + Ok(()) + } + } + } + pub fn drop_cf(&self, name: &str) -> Result<(), rocksdb::Error> { match &self.storage { - Storage::Rocks(db) => db.underlying.drop_cf(name), + Storage::Rocks(db) => { + db.underlying.drop_cf(name)?; + db.cf_names + .write() + .expect("lock should not be poisoned") + .retain(|cf| cf != name); + Ok(()) + } Storage::InMemory(db) => { db.drop_cf(name); Ok(()) @@ -341,7 +380,12 @@ impl Database { // See `flush_cf` for why the flushes run off the test thread under // the simulator. Storage::Rocks(rocks) => nondeterministic!({ - for cf_name in &rocks.cf_names { + let cf_names = rocks + .cf_names + .read() + .expect("lock should not be poisoned") + .clone(); + for cf_name in &cf_names { if let Some(cf) = rocks.underlying.cf_handle(cf_name) { rocks.underlying.flush_cf(&cf).map_err(|e| { TypedStoreError::RocksDB(format!( @@ -492,7 +536,7 @@ impl DBMap { db: Arc, opts: &ReadWriteOptions, column_family: ColumnFamily, - is_deprecated: bool, + skip_metrics_reporting: bool, ) -> Self { let db_cloned = Arc::downgrade(&db); let db_metrics = DBMetrics::get(); @@ -500,7 +544,7 @@ impl DBMap { let cf = column_family.name().to_string(); let (sender, mut recv) = tokio::sync::oneshot::channel(); - if !is_deprecated && matches!(db.storage, Storage::Rocks(_)) { + if !skip_metrics_reporting && matches!(db.storage, Storage::Rocks(_)) { tokio::task::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(CF_METRICS_REPORT_PERIOD_SECS)); @@ -542,12 +586,16 @@ impl DBMap { /// Reopens an open database as a typed map operating under a specific /// column family. if no column family is passed, the default column /// family is used. + /// + /// When `skip_metrics_reporting` is true, no periodic per-column-family + /// metrics task is spawned; use this for deprecated tables and for large + /// sets of rarely-touched column families. #[instrument(level = "debug", skip(db), err)] pub fn reopen( db: &Arc, opt_cf: Option<&str>, rw_options: &ReadWriteOptions, - is_deprecated: bool, + skip_metrics_reporting: bool, ) -> Result { let cf_key = opt_cf .unwrap_or(rocksdb::DEFAULT_COLUMN_FAMILY_NAME) @@ -561,7 +609,7 @@ impl DBMap { db.clone(), rw_options, column_family, - is_deprecated, + skip_metrics_reporting, )) } diff --git a/crates/typed-store/src/memstore.rs b/crates/typed-store/src/memstore.rs index 4d9961218a7e..adfad1d02ea2 100644 --- a/crates/typed-store/src/memstore.rs +++ b/crates/typed-store/src/memstore.rs @@ -95,6 +95,14 @@ impl InMemoryDB { } } + pub fn create_cf(&self, name: &str) { + self.data + .write() + .expect("can't write data") + .entry(name.to_string()) + .or_default(); + } + pub fn has_cf(&self, name: &str) -> bool { self.data .read() diff --git a/crates/typed-store/src/rocks/mod.rs b/crates/typed-store/src/rocks/mod.rs index 28d184002756..8c8df6759850 100644 --- a/crates/typed-store/src/rocks/mod.rs +++ b/crates/typed-store/src/rocks/mod.rs @@ -11,7 +11,7 @@ use std::{ collections::HashSet, ffi::CStr, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, RwLock}, time::Duration, }; @@ -53,8 +53,9 @@ mod tests; #[derive(Debug)] pub(crate) struct RocksDB { pub(crate) underlying: rocksdb::DBWithThreadMode, - /// Names of all column families opened on this database. - pub(crate) cf_names: Vec, + /// Names of all column families opened on this database, kept in sync + /// with column families created or dropped at runtime. + pub(crate) cf_names: RwLock>, } impl Drop for RocksDB { @@ -141,7 +142,7 @@ pub fn open_cf_opts>( Ok(Arc::new(Database::new( Storage::Rocks(RocksDB { underlying: rocksdb, - cf_names, + cf_names: RwLock::new(cf_names), }), metric_conf, ))) @@ -209,7 +210,7 @@ pub fn open_cf_opts_secondary>( Ok(Arc::new(Database::new( Storage::Rocks(RocksDB { underlying: rocksdb, - cf_names, + cf_names: RwLock::new(cf_names), }), metric_conf, ))) diff --git a/crates/typed-store/src/rocks/tests.rs b/crates/typed-store/src/rocks/tests.rs index 0e809ec0da79..403b7a617c2a 100644 --- a/crates/typed-store/src/rocks/tests.rs +++ b/crates/typed-store/src/rocks/tests.rs @@ -1036,6 +1036,62 @@ async fn open_as_secondary_test() { assert_eq!(secondary_db.get(&0).unwrap(), Some("10".to_string())); } +#[tokio::test] +async fn test_create_cf_at_runtime() { + let tmp_dir = iota_common::tempdir(); + let path = tmp_dir.path(); + { + let db = open_rocksdb(path, &["static_cf"]); + assert!(db.cf_handle("dynamic_cf").is_none()); + db.create_cf("dynamic_cf", &rocksdb::Options::default()) + .expect("Failed to create column family"); + assert!(db.cf_handle("dynamic_cf").is_some()); + + // Creating an already-existing column family must fail. + assert!( + db.create_cf("dynamic_cf", &rocksdb::Options::default()) + .is_err() + ); + + let map = DBMap::::reopen( + &db, + Some("dynamic_cf"), + &ReadWriteOptions::default(), + false, + ) + .expect("Failed to open map on dynamic column family"); + map.insert(&1, &"one".to_string()) + .expect("Failed to insert"); + assert_eq!(map.get(&1).unwrap(), Some("one".to_string())); + + // The dynamically created column family must be covered by flush_all. + db.flush_all().expect("Failed to flush"); + } + { + // The column family is rediscovered when reopening the database from + // disk without declaring it. + let db = open_rocksdb(path, &["static_cf"]); + assert!(db.cf_handle("dynamic_cf").is_some()); + let map = DBMap::::reopen( + &db, + Some("dynamic_cf"), + &ReadWriteOptions::default(), + false, + ) + .expect("Failed to open map on rediscovered column family"); + assert_eq!(map.get(&1).unwrap(), Some("one".to_string())); + + db.drop_cf("dynamic_cf") + .expect("Failed to drop column family"); + assert!(db.cf_handle("dynamic_cf").is_none()); + } + { + // A dropped column family stays gone after reopening. + let db = open_rocksdb(path, &["static_cf"]); + assert!(db.cf_handle("dynamic_cf").is_none()); + } +} + fn open_map, K, V>(path: P, opt_cf: Option<&str>) -> DBMap { let cf_key = opt_cf.unwrap_or(rocksdb::DEFAULT_COLUMN_FAMILY_NAME); DBMap::::reopen( diff --git a/docs/content/operator/common/pruning.mdx b/docs/content/operator/common/pruning.mdx index feac9ddc1f08..01d54e455484 100644 --- a/docs/content/operator/common/pruning.mdx +++ b/docs/content/operator/common/pruning.mdx @@ -1,25 +1,19 @@ import Quiz from '@site/src/components/Quiz'; import questions from '/json/node-operators/iota-full-node/pruning.json'; -Sustainable disk usage requires IOTA full nodes to prune the information about historic object versions as well as historic transactions with the corresponding effects and events, including old checkpoint data. +Sustainable disk usage requires IOTA nodes to prune the information about historic object versions as well as historic transactions with the corresponding effects and events, including old checkpoint data. -Both transaction and object pruners run in the background. The logical deletion of entries from RocksDB ultimately triggers the physical compaction of data on disk, which is governed by RocksDB background jobs: the pruning effect on disk usage is not immediate and might take multiple days. - -:::tip - -Testing indicates that aggressive pruning results in more efficient full node operation. - -::: +Pruned data is not deleted immediately. It is first relocated into per-epoch historic buckets, where it stays readable through gRPC and state sync for a configurable number of epochs, and whole buckets are dropped once they fall out of that window. Dropping a bucket removes its files outright, so reclaiming disk space does not depend on background compaction of the live tables. ## Types of Pruning ### Object Pruning -IOTA adds new object versions to the database as part of transaction execution, which makes previous versions ready for garbage collection. Without pruning, this would result in database performance degradation and require large amounts of storage space. IOTA identifies the objects that are eligible for pruning in each checkpoint, and then performs the pruning in the background. +IOTA adds new object versions to the database as part of transaction execution, which makes previous versions historic. Without pruning, this would result in database performance degradation and require large amounts of storage space. Superseded object versions are moved out of the live objects table into the historic bucket of the epoch in which they were superseded — atomically, as part of committing the checkpoint that superseded them — so the live table only ever holds the latest version of each object. Exact-version lookups (gRPC) are served from the historic buckets until the bucket expires. ### Transaction Pruning -Transaction pruning removes previous transactions and their effects from the database. IOTA periodically creates checkpoints, and each checkpoint contains the transactions that occurred during the checkpoint and their associated effects. IOTA performs transaction pruning in the background after checkpoints complete. +Transaction pruning moves previous transactions and their effects, events, and checkpoint data from the live tables into the historic buckets. IOTA periodically creates checkpoints, and each checkpoint contains the transactions that occurred during the checkpoint and their associated effects. IOTA performs transaction pruning in the background after checkpoints complete. Relocated checkpoint data remains available to gRPC and state-sync peers until its bucket expires. ### Index Pruning @@ -27,13 +21,13 @@ Index pruning advances a watermark in the secondary index stores (JSON-RPC and g ## Pruning Parameters -Object pruning, transaction pruning, and index pruning are independent background tasks. Each task is gated by its own parameter, so you can enable or disable them individually: +Object relocation is always on. Transaction pruning and index pruning are independent background tasks gated by their own parameters, and the historic retention window controls how long relocated data of both kinds stays readable: -| Pruning task | Controlling parameter | Enabled when | -| --------------------- | -------------------------------------- | --------------------------------- | -| Object pruning | `num-epochs-to-retain` | value is not `u64::MAX` | -| Transaction pruning | `num-epochs-to-retain-for-checkpoints` | set to a value `>= 2` | -| Index pruning | `num-epochs-to-retain-for-indexes` | set | +| Task | Controlling parameter | Behavior | +| --------------------- | -------------------------------------- | -------------------------------------------- | +| Historic retention | `historic-epochs-to-retain` | always on; buckets drop after this many epochs | +| Transaction pruning | `num-epochs-to-retain-for-checkpoints` | enabled when set to a value `>= 2` | +| Index pruning | `num-epochs-to-retain-for-indexes` | enabled when set | All pruning parameters are configured in `fullnode.yaml`. The following annotated block shows every parameter with its default value — copy it into your config and adjust as needed: @@ -44,19 +38,21 @@ All pruning parameters are configured in `fullnode.yaml`. The following annotate enable-index-processing: true authority-store-pruning-config: - # Number of epochs to keep historic object versions. Controls object pruning. - # 0 — aggressive pruning (default). Prunes old versions as soon as - # possible. Lowest disk usage. Recommended for validators and full - # nodes that do not serve historic object queries. - # N (>= 1) — retains object versions from the last N epochs. Use this when - # the node must serve lookups by object ID + version. - # u64::MAX — disables object pruning entirely (18446744073709551615). - num-epochs-to-retain: 0 + # Number of epochs of historic data (superseded object versions and pruned + # transactions, effects, events, and checkpoint data) to keep readable. + # Whole epoch buckets are dropped once they fall out of this window. + # 2 (default) — keeps roughly the disk profile of an aggressively pruned + # node. Recommended for validators and full nodes that do + # not serve historic queries. + # N — RPC full nodes that serve exact-version object lookups or + # historic checkpoint data typically raise this (e.g. 100). + historic-epochs-to-retain: 2 # Number of epochs to keep historic transactions, effects, events, and - # checkpoint data. Controls transaction pruning. + # checkpoint data in the live tables before they are relocated into the + # historic buckets. Controls transaction pruning. # unset / 0 / u64::MAX — transaction pruning disabled (default). - # N (>= 2) — prunes data older than current − N epochs. The + # N (>= 2) — relocates data older than current − N epochs. The # minimum effective value is 2; IOTA always keeps # the current and immediately prior epoch. # num-epochs-to-retain-for-checkpoints: 2 @@ -76,16 +72,14 @@ authority-store-pruning-config: # Reclaims disk space and avoids fragmentation. Default: 1 (when read from # config). Set to "null" to disable periodic compaction entirely. periodic-compaction-threshold-days: 1 - - # Use the RocksDB compaction filter for object-table pruning. When disabled, - # a range-deletion approach is used. Do not toggle frequently — switching - # back to range deletion may leave some old versions unpruned. Default: false. - # enable-compaction-filter: false ``` Pruning is driven by checkpoint execution: after each executed checkpoint the -node prunes data that has aged past the retention window, pacing the work -against on-chain checkpoint timestamps. +node relocates and expires data that has aged past the retention window, +pacing the work against on-chain checkpoint timestamps. Pruning never blocks +execution; if it falls behind (for example after downtime or during catch-up +sync), the database grows temporarily and the node reports the lag through +the `pruning_chain_time_lag_ms` metric and a log warning until it catches up. ## Set an Archiving Watermark @@ -97,48 +91,44 @@ The following examples cover common operator use cases. Each example shows the k ### Validator or Minimal Full Node -This configuration keeps disk usage to a minimum. A node with this setup cannot answer queries that require indexing or historic data. It is the recommended setup for IOTA validators and for full nodes that do not serve RPC requests. +This configuration keeps disk usage to a minimum. A node with this setup cannot answer queries that require indexing or long historic retention. It is the recommended setup for IOTA validators and for full nodes that do not serve RPC requests. ```yaml # Do not generate or maintain indexing of IOTA data on the node enable-index-processing: false authority-store-pruning-config: - # Aggressively prune historic object versions - num-epochs-to-retain: 0 + # Keep only the minimum historic window + historic-epochs-to-retain: 2 # Prune historic transactions of past epochs num-epochs-to-retain-for-checkpoints: 2 ``` ### Full Node with Indexing but no History -This setup manages secondary indexing in addition to the latest state, but aggressively prunes historic data. A full node with this configuration: +This setup manages secondary indexing in addition to the latest state, but keeps only the minimum historic window. A full node with this configuration: - Answers RPC queries that require indexing, like `iotax_getBalance()`. - Answers RPC queries that require historic transactions via a fallback to retrieve the data from a remote key-value store: `iota_getTransactionBlock()`. -- Cannot answer RPC queries that require historic object versions: `iota_tryGetPastObject()`. +- Cannot answer JSON-RPC queries that require historic object versions: `iota_tryGetPastObject()`. - The `showBalanceChanges` filter of `iota_getTransactionBlock()` relies on historic object versions, so it cannot work with this configuration. ```yaml authority-store-pruning-config: - # Aggressively prune historic object versions - num-epochs-to-retain: 0 + # Keep only the minimum historic window + historic-epochs-to-retain: 2 # Prune historic transactions of past epochs num-epochs-to-retain-for-checkpoints: 2 ``` -### Full Node with Full Object History but Pruned Transaction History - -This configuration manages the full object history while still pruning historic transactions. A full node with this configuration can answer all historic and indexing queries (using the transaction query fallback for transactional data), including the ones that require historic objects such as the `showBalanceChanges` filter of `iota_getTransactionBlock()`. - -The main caveat is that the current setup enables the **transaction pruner** to go ahead of the **object pruner**. The object pruner might not be able to properly clean up the objects modified by the transactions that have been already pruned. You should closely monitor the disk space growth on a full node with this configuration. +### RPC Full Node Serving History -In addition to the regular (pruned) snapshots, the IOTA Foundation also maintains special RocksDB snapshots with the full history of object versions available for the operators using this configuration. +This configuration retains a long window of historic data. A full node with this configuration serves exact-version object lookups over gRPC and historic checkpoint data to gRPC clients and state-sync peers for the whole retention window, while its live tables stay as small as an aggressively pruned node's. Disk usage grows with the retention window: budget roughly the live state plus one window's worth of superseded data and pruned checkpoint history. ```yaml authority-store-pruning-config: - # No pruning of object versions (use u64::max for num of epochs) - num-epochs-to-retain: 18446744073709551615 + # Keep about three months of history (with ~1-day epochs) + historic-epochs-to-retain: 100 # Prune historic transactions of the past epochs num-epochs-to-retain-for-checkpoints: 2 ``` diff --git a/setups/fullnode/fullnode-devnet.yaml b/setups/fullnode/fullnode-devnet.yaml index 60f622cb10d5..e084967d974b 100644 --- a/setups/fullnode/fullnode-devnet.yaml +++ b/setups/fullnode/fullnode-devnet.yaml @@ -11,8 +11,9 @@ migration-tx-data-path: "/opt/iota/config/migration.blob" # Pruning Configuration (see https://docs.iota.org/operator/full-node/configs/pruning) authority-store-pruning-config: - # Use 18446744073709551615 to disable object versions pruning - num-epochs-to-retain: 1 + # Number of epochs of superseded object versions and pruned checkpoint + # data to keep readable; whole epoch buckets are dropped past this window. + historic-epochs-to-retain: 2 # Use 18446744073709551615 to disable transaction pruning num-epochs-to-retain-for-checkpoints: 2 diff --git a/setups/fullnode/fullnode-mainnet.yaml b/setups/fullnode/fullnode-mainnet.yaml index 9310538b1670..50fd9a5604a3 100644 --- a/setups/fullnode/fullnode-mainnet.yaml +++ b/setups/fullnode/fullnode-mainnet.yaml @@ -11,8 +11,9 @@ migration-tx-data-path: "/opt/iota/config/migration.blob" # Pruning Configuration (see https://docs.iota.org/operator/full-node/configs/pruning) authority-store-pruning-config: - # Use 18446744073709551615 to disable object versions pruning - num-epochs-to-retain: 1 + # Number of epochs of superseded object versions and pruned checkpoint + # data to keep readable; whole epoch buckets are dropped past this window. + historic-epochs-to-retain: 2 # Use 18446744073709551615 to disable transaction pruning num-epochs-to-retain-for-checkpoints: 2 diff --git a/setups/fullnode/fullnode-testnet.yaml b/setups/fullnode/fullnode-testnet.yaml index 0e29823251ba..0e4ea97cc5d5 100644 --- a/setups/fullnode/fullnode-testnet.yaml +++ b/setups/fullnode/fullnode-testnet.yaml @@ -10,8 +10,9 @@ genesis: # Pruning Configuration (see https://docs.iota.org/operator/full-node/configs/pruning) authority-store-pruning-config: - # Use 18446744073709551615 to disable object versions pruning - num-epochs-to-retain: 1 + # Number of epochs of superseded object versions and pruned checkpoint + # data to keep readable; whole epoch buckets are dropped past this window. + historic-epochs-to-retain: 2 # Use 18446744073709551615 to disable transaction pruning num-epochs-to-retain-for-checkpoints: 2 diff --git a/setups/validator/ssfn-mainnet.yaml b/setups/validator/ssfn-mainnet.yaml index 2680853fb3d9..ed0f46bdc7f7 100644 --- a/setups/validator/ssfn-mainnet.yaml +++ b/setups/validator/ssfn-mainnet.yaml @@ -29,7 +29,6 @@ p2p-config: # Pruning Configuration authority-store-pruning-config: - num-epochs-to-retain: 0 num-epochs-to-retain-for-checkpoints: 2 periodic-compaction-threshold-days: 1 diff --git a/setups/validator/ssfn-testnet.yaml b/setups/validator/ssfn-testnet.yaml index 13fd9b2312c2..49c4fe0d7ac2 100644 --- a/setups/validator/ssfn-testnet.yaml +++ b/setups/validator/ssfn-testnet.yaml @@ -24,7 +24,6 @@ p2p-config: # Pruning Configuration authority-store-pruning-config: - num-epochs-to-retain: 0 num-epochs-to-retain-for-checkpoints: 2 periodic-compaction-threshold-days: 1 diff --git a/setups/validator/validator-mainnet.yaml b/setups/validator/validator-mainnet.yaml index 6cb92bb57576..48af4f50927d 100644 --- a/setups/validator/validator-mainnet.yaml +++ b/setups/validator/validator-mainnet.yaml @@ -30,7 +30,6 @@ p2p-config: # Pruning Configuration authority-store-pruning-config: - num-epochs-to-retain: 0 num-epochs-to-retain-for-checkpoints: 2 # Resource Optimization diff --git a/setups/validator/validator-testnet.yaml b/setups/validator/validator-testnet.yaml index c6e7580563ea..44edbb5f0cf5 100644 --- a/setups/validator/validator-testnet.yaml +++ b/setups/validator/validator-testnet.yaml @@ -29,7 +29,6 @@ p2p-config: # Pruning Configuration authority-store-pruning-config: - num-epochs-to-retain: 0 num-epochs-to-retain-for-checkpoints: 2 # Resource Optimization