From d752c0bca39094826dfca4bd20d6d4be5e688d7a Mon Sep 17 00:00:00 2001 From: muXxer Date: Fri, 3 Jul 2026 11:36:32 +0200 Subject: [PATCH 01/12] feat(core): add live/historic objects split with per-epoch history buckets --- crates/iota-config/src/node.rs | 49 ++ crates/iota-core/src/authority.rs | 16 + .../src/authority/authority_store.rs | 1 + .../src/authority/authority_store_pruner.rs | 606 ++++++++++++++++- .../src/authority/historic_object_store.rs | 615 ++++++++++++++++++ .../src/authority/test_authority_builder.rs | 1 + crates/iota-core/src/db_checkpoint_handler.rs | 3 + crates/iota-core/src/storage.rs | 38 +- crates/iota-node/src/lib.rs | 38 ++ crates/iota-tool/src/db_tool/db_dump.rs | 1 + crates/typed-store/src/database.rs | 47 +- crates/typed-store/src/memstore.rs | 8 + crates/typed-store/src/rocks/mod.rs | 11 +- crates/typed-store/src/rocks/tests.rs | 56 ++ 14 files changed, 1469 insertions(+), 21 deletions(-) create mode 100644 crates/iota-core/src/authority/historic_object_store.rs diff --git a/crates/iota-config/src/node.rs b/crates/iota-config/src/node.rs index aee7e5d5025b..0a2671af2eb0 100644 --- a/crates/iota-config/src/node.rs +++ b/crates/iota-config/src/node.rs @@ -1058,6 +1058,54 @@ pub struct AuthorityStorePruningConfig { pub enable_compaction_filter: bool, #[serde(skip_serializing_if = "Option::is_none")] pub num_epochs_to_retain_for_indexes: Option, + /// Enables the live/historic object split: instead of deleting superseded + /// object versions after `num_epochs_to_retain` epochs, the pruner + /// relocates them into per-epoch historic stores where they remain + /// readable through exact-version RPC lookups and are dropped wholesale + /// once out of retention. Fullnode-only; incompatible with + /// `enable_compaction_filter`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub historic_object_store: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct HistoricObjectStoreConfig { + /// Number of epochs of superseded object versions to retain, bucketed by + /// the epoch in which they were superseded. Whole epoch buckets are + /// dropped once they fall out of this window. + #[serde(default = "default_historic_epochs_to_retain")] + pub num_epochs_to_retain: u64, + /// Byte budget for a single relocation write batch. + #[serde(default = "default_max_relocation_batch_bytes")] + pub max_relocation_batch_bytes: usize, + /// Skip the write-ahead log for relocation writes. Safe because + /// relocation is idempotent and flushed before the source rows are + /// deleted; disable only for debugging. + #[serde(default = "default_historic_disable_wal")] + pub disable_wal: bool, +} + +fn default_historic_epochs_to_retain() -> u64 { + 100 +} + +fn default_max_relocation_batch_bytes() -> usize { + 256 * 1024 * 1024 +} + +fn default_historic_disable_wal() -> bool { + true +} + +impl Default for HistoricObjectStoreConfig { + fn default() -> Self { + Self { + num_epochs_to_retain: default_historic_epochs_to_retain(), + max_relocation_batch_bytes: default_max_relocation_batch_bytes(), + disable_wal: default_historic_disable_wal(), + } + } } fn default_num_latest_epoch_dbs_to_retain() -> usize { @@ -1077,6 +1125,7 @@ impl Default for AuthorityStorePruningConfig { 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_object_store: None, } } } diff --git a/crates/iota-core/src/authority.rs b/crates/iota-core/src/authority.rs index 1018db1c53f2..808678207736 100644 --- a/crates/iota-core/src/authority.rs +++ b/crates/iota-core/src/authority.rs @@ -159,6 +159,7 @@ use crate::{ authority_store_pruner::{AuthorityStorePruner, EPOCH_DURATION_MS_FOR_TESTING}, authority_store_tables::AuthorityPrunerTables, epoch_start_configuration::{EpochStartConfigTrait, EpochStartConfiguration}, + historic_object_store::HistoricObjectStore, }, authority_client::NetworkAuthorityClient, checkpoint_progress_tracker::CheckpointProgressTracker, @@ -233,6 +234,7 @@ pub mod authority_store_pruner; pub mod authority_store_tables; pub mod authority_store_types; pub mod epoch_start_configuration; +pub mod historic_object_store; pub mod shared_object_congestion_tracker; pub mod shared_object_version_manager; pub mod suggested_gas_price_calculator; @@ -853,6 +855,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_object_store: Option>, + pub subscription_handler: Arc, pub checkpoint_store: Arc, @@ -3259,6 +3266,7 @@ impl AuthorityState { validator_tx_finalizer: Option>>, chain_identifier: ChainIdentifier, pruner_db: Option>, + historic_object_store: Option>, checkpoint_progress_tracker: Option>, policy_config: Option, firewall_config: Option, @@ -3296,6 +3304,7 @@ impl AuthorityState { prometheus_registry, archive_readers, pruner_db, + historic_object_store.clone(), checkpoint_progress_tracker.clone(), ); let input_loader = @@ -3325,6 +3334,7 @@ impl AuthorityState { execution_cache_trait_pointers, indexes, grpc_indexes_store, + historic_object_store, subscription_handler: Arc::new(SubscriptionHandler::new(prometheus_registry)), checkpoint_store, committee_store, @@ -3834,6 +3844,12 @@ impl AuthorityState { self.get_reconfig_api() .try_checkpoint_db(&store_checkpoint_path_tmp.join("perpetual"))?; + // The historic object store is intentionally not part of DB + // checkpoints: it only holds superseded object versions served over + // gRPC, can grow to terabytes, and a node restored without it stays + // fully consensus-consistent (it merely answers NotFound for + // relocated versions). + self.committee_store .checkpoint_db(&checkpoint_path_tmp.join("epochs"))?; diff --git a/crates/iota-core/src/authority/authority_store.rs b/crates/iota-core/src/authority/authority_store.rs index b4fad13f1f6e..2c232c9c64ff 100644 --- a/crates/iota-core/src/authority/authority_store.rs +++ b/crates/iota-core/src/authority/authority_store.rs @@ -1665,6 +1665,7 @@ impl AuthorityStore { checkpoint_store, grpc_indexes_store, None, + None, pruning_config, AuthorityStorePruningMetrics::new_for_test(), EPOCH_DURATION_MS_FOR_TESTING, diff --git a/crates/iota-core/src/authority/authority_store_pruner.rs b/crates/iota-core/src/authority/authority_store_pruner.rs index c766e8d7d5d2..7edb7c1fc747 100644 --- a/crates/iota-core/src/authority/authority_store_pruner.rs +++ b/crates/iota-core/src/authority/authority_store_pruner.rs @@ -17,6 +17,7 @@ use iota_metrics::{monitored_scope, spawn_monitored_task}; use iota_sdk_types::ObjectId; use iota_types::{ base_types::{SequenceNumber, VersionNumber}, + committee::EpochId, effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt}, messages_checkpoint::{ CheckpointContents, CheckpointContentsExt, CheckpointDigest, CheckpointSequenceNumber, @@ -39,12 +40,16 @@ use tokio::{ use tracing::{debug, error, info, warn}; use typed_store::{ Map, TypedStoreError, + rocks::DBBatch, rocksdb::{LiveFile, compaction_filter::Decision}, }; use super::authority_store_tables::{AuthorityPerpetualTables, AuthorityPrunerTables}; use crate::{ - authority::authority_store_types::{StoreObject, StoreObjectWrapper}, + authority::{ + authority_store_types::{StoreObject, StoreObjectWrapper}, + historic_object_store::HistoricObjectStore, + }, checkpoint_progress_tracker::CheckpointProgressTracker, checkpoints::{CheckpointStore, CheckpointWatermark}, grpc_indexes::GrpcIndexesStore, @@ -230,12 +235,22 @@ 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, + max_batch_bytes: usize, +} + impl AuthorityStorePruner { /// prunes old versions of objects based on transaction effects async fn prune_objects( transaction_effects: Vec, perpetual_db: &Arc, pruner_db: Option<&Arc>, + relocation: Option>, checkpoint_number: CheckpointSequenceNumber, metrics: Arc, ) -> anyhow::Result<()> { @@ -264,6 +279,24 @@ impl AuthorityStorePruner { .num_pruned_tombstones .inc_by(object_tombstones_to_prune.len() as u64); + if let Some(relocation) = relocation { + debug_assert!( + pruner_db.is_none(), + "the compaction filter and the historic store are mutually exclusive" + ); + 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()?; + return Ok(()); + } + let mut updates: HashMap = HashMap::new(); for ObjectKey(object_id, seq_number) in live_object_keys_to_prune { updates @@ -325,6 +358,76 @@ impl AuthorityStorePruner { Ok(()) } + /// Moves superseded object versions into the historic store instead of + /// deleting them, and schedules the point deletes of the relocated keys + /// into `wb`. + /// + /// The historic writes are flushed before this function returns, so the + /// caller may commit `wb` (which also advances the pruning watermark) + /// afterwards: on a crash in between, replay finds the not-yet-deleted + /// live rows and rewrites identical historic rows. + /// + /// 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<()> { + let HistoricRelocation { + store, + supersession_epoch, + max_batch_bytes, + } = relocation; + + // Keys already absent were relocated by a previous run that crashed + // before committing the deletes; skipping them keeps replay + // idempotent. + let values = perpetual_db + .objects + .multi_get(live_object_keys_to_prune.iter())?; + let mut relocated_keys = Vec::with_capacity(live_object_keys_to_prune.len()); + let mut chunk = Vec::new(); + let mut chunk_bytes = 0usize; + let mut tombstone_heads = Some(tombstone_heads); + for (key, value) in live_object_keys_to_prune.into_iter().zip(values) { + let Some(value) = value else { + continue; + }; + relocated_keys.push(key); + chunk_bytes += bcs::serialized_size(&value)?; + chunk.push((key, value)); + if chunk_bytes >= max_batch_bytes { + store.put_objects( + supersession_epoch, + &chunk, + &tombstone_heads.take().unwrap_or_default(), + )?; + chunk.clear(); + chunk_bytes = 0; + } + } + store.put_objects( + supersession_epoch, + &chunk, + &tombstone_heads.take().unwrap_or_default(), + )?; + // Durability barrier: the relocated rows must be on disk before the + // live rows disappear. + store.flush_epoch(supersession_epoch)?; + + wb.delete_batch(&perpetual_db.objects, relocated_keys)?; + Ok(()) + } + /// Prunes checkpoint-related data from the `AuthorityStore`, including /// transaction effects, executed transactions, and checkpoint contents, /// based on the specified checkpoint number and list of checkpoints to @@ -415,6 +518,7 @@ impl AuthorityStorePruner { checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, pruner_db: Option<&Arc>, + historic_store: Option<&Arc>, config: AuthorityStorePruningConfig, metrics: Arc, epoch_duration_ms: u64, @@ -436,11 +540,18 @@ impl AuthorityStorePruner { let pruned_checkpoint_number = perpetual_db .get_highest_pruned_checkpoint()? .unwrap_or_default(); + let max_relocation_batch_bytes = config + .historic_object_store + .as_ref() + .map(|c| c.max_relocation_batch_bytes) + .unwrap_or(usize::MAX); Self::prune_for_eligible_epochs( perpetual_db, checkpoint_store, grpc_indexes_store, pruner_db, + historic_store, + max_relocation_batch_bytes, PruningMode::Objects, config.num_epochs_to_retain, pruned_checkpoint_number, @@ -503,6 +614,8 @@ impl AuthorityStorePruner { checkpoint_store, grpc_indexes_store, pruner_db, + None, + usize::MAX, PruningMode::Checkpoints, num_epochs_to_retain, pruned_checkpoint_number, @@ -521,6 +634,8 @@ impl AuthorityStorePruner { checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, pruner_db: Option<&Arc>, + historic_store: Option<&Arc>, + max_relocation_batch_bytes: usize, mode: PruningMode, num_epochs_to_retain: u64, starting_checkpoint_number: CheckpointSequenceNumber, @@ -531,6 +646,12 @@ impl AuthorityStorePruner { ) -> anyhow::Result<()> { let _scope = monitored_scope("PruneForEligibleEpochs"); + // Relocation only applies to object pruning. + let historic_store = match mode { + PruningMode::Objects => historic_store, + PruningMode::Checkpoints => None, + }; + let mut checkpoint_number = starting_checkpoint_number; let current_epoch = checkpoint_store .get_highest_executed_checkpoint()? @@ -540,6 +661,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,6 +687,54 @@ impl AuthorityStorePruner { { break; } + + // With relocation enabled a batch must not span epochs: relocated + // rows are bucketed by the epoch of the checkpoint that superseded + // them. Flush the pending batch before crossing the boundary and + // seal the finished epoch's bucket. + if let Some(store) = historic_store { + match batch_epoch { + Some(epoch) if epoch != checkpoint.epoch() => { + if !checkpoints_to_prune.is_empty() { + Self::prune_objects( + std::mem::take(&mut effects_to_prune), + perpetual_db, + pruner_db, + Some(HistoricRelocation { + store, + supersession_epoch: epoch, + max_batch_bytes: max_relocation_batch_bytes, + }), + checkpoint_number, + metrics.clone(), + ) + .await?; + checkpoints_to_prune = vec![]; + checkpoint_content_to_prune = vec![]; + if let Some(tracker) = progress_tracker { + tracker.add_object_pruning_time(pruning_start.elapsed()); + pruning_start = Instant::now(); + } + } + store.seal_epoch(epoch)?; + } + None => { + // A previous run may have finished exactly at an + // epoch boundary without sealing; catch up on any + // unsealed earlier buckets. + for epoch in store.list_epochs() { + if epoch >= checkpoint.epoch() { + break; + } + if !store.is_sealed(epoch)? { + store.seal_epoch(epoch)?; + } + } + } + _ => {} + } + } + batch_epoch = Some(checkpoint.epoch()); checkpoint_number = checkpoint.sequence_number(); let content = checkpoint_store @@ -593,6 +763,12 @@ impl AuthorityStorePruner { effects_to_prune, perpetual_db, pruner_db, + historic_store.map(|store| HistoricRelocation { + store, + supersession_epoch: batch_epoch + .expect("batch epoch is set before batching"), + max_batch_bytes: max_relocation_batch_bytes, + }), checkpoint_number, metrics.clone(), ) @@ -637,6 +813,12 @@ impl AuthorityStorePruner { effects_to_prune, perpetual_db, pruner_db, + historic_store.map(|store| HistoricRelocation { + store, + supersession_epoch: batch_epoch + .expect("batch epoch is set before batching"), + max_batch_bytes: max_relocation_batch_bytes, + }), checkpoint_number, metrics.clone(), ) @@ -669,6 +851,42 @@ impl AuthorityStorePruner { Ok(()) } + /// 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(()) + } + fn prune_indexes( indexes: Option<&IndexStore>, config: &AuthorityStorePruningConfig, @@ -756,6 +974,7 @@ impl AuthorityStorePruner { grpc_indexes_store: Option>, jsonrpc_index: Option>, pruner_db: Option>, + historic_store: Option>, metrics: Arc, archive_readers: ArchiveReaderBalancer, progress_tracker: Option>, @@ -768,6 +987,12 @@ impl AuthorityStorePruner { config.num_epochs_to_retain ); + let historic_epochs_to_retain = config + .historic_object_store + .as_ref() + .map(|c| c.num_epochs_to_retain) + .unwrap_or(u64::MAX); + // Periodic background compaction of aged SST files, independent of the // execution-driven pruning loop below. let perpetual_db_for_compaction = perpetual_db.clone(); @@ -860,6 +1085,7 @@ impl AuthorityStorePruner { &checkpoint_store, grpc_indexes_store.as_deref(), pruner_db.as_ref(), + historic_store.as_ref(), config.clone(), metrics.clone(), epoch_duration_ms, @@ -870,6 +1096,24 @@ impl AuthorityStorePruner { error!("Failed to prune objects: {:?}", err); } } + // 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. Not part of + // the leash — dropping old buckets never blocks execution. + if let Some(store) = &historic_store { + 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, + 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, @@ -934,6 +1178,7 @@ impl AuthorityStorePruner { registry: &Registry, archive_readers: ArchiveReaderBalancer, pruner_db: Option>, + mut historic_store: Option>, progress_tracker: Option>, ) -> Self { if pruning_config.num_epochs_to_retain > 0 && pruning_config.num_epochs_to_retain < u64::MAX @@ -949,12 +1194,35 @@ impl AuthorityStorePruner { warn!("Consider using an aggressive pruner (num_epochs_to_retain = 0)"); } } + + assert!( + historic_store.is_none() || pruner_db.is_none(), + "the compaction filter pruner and the historic object store are mutually exclusive" + ); + if is_validator && historic_store.is_some() { + warn!("The historic object store is fullnode-only; disabling it on this validator."); + historic_store = None; + } + if historic_store.is_some() && pruning_config.num_epochs_to_retain == u64::MAX { + warn!( + "The historic object store is enabled but the objects pruner is disabled \ + (num_epochs_to_retain = u64::MAX); no object versions will ever be relocated." + ); + } + if historic_store.is_some() { + 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`. let (executed, executed_rx) = watch::channel(0); let (frontier_ms, _) = watch::channel(u64::MAX); + AuthorityStorePruner { _objects_pruner_cancel_handle: Self::setup_pruning( pruning_config, @@ -964,6 +1232,7 @@ impl AuthorityStorePruner { grpc_indexes_store, jsonrpc_index, pruner_db, + historic_store, AuthorityStorePruningMetrics::new(registry), archive_readers, progress_tracker, @@ -975,6 +1244,41 @@ impl AuthorityStorePruner { } } + /// 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(()) + } + /// 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> { @@ -1065,7 +1369,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 +1385,13 @@ mod tests { rocks::{DBMap, MetricConf, ReadWriteOptions, default_db_options}, }; - use super::{AuthorityStorePruner, PRUNING_LEASH_SLACK_MS, PruningMode}; + use super::{AuthorityStorePruner, HistoricRelocation, PRUNING_LEASH_SLACK_MS, PruningMode}; use crate::{ authority::{ authority_store_pruner::AuthorityStorePruningMetrics, authority_store_tables::AuthorityPerpetualTables, authority_store_types::{StoreObject, StoreObjectWrapper, get_store_object}, + historic_object_store::{HistoricObjectStore, HistoricObjectStoreMetrics}, }, checkpoints::CheckpointStore, }; @@ -1212,7 +1518,7 @@ mod tests { ObjectDigest::MIN, )); } - AuthorityStorePruner::prune_objects(vec![effects], &db, None, 0, metrics) + AuthorityStorePruner::prune_objects(vec![effects], &db, None, None, 0, metrics) .await .unwrap(); to_keep @@ -1221,6 +1527,284 @@ mod tests { to_keep } + fn open_historic(path: &Path) -> Arc { + Arc::new( + HistoricObjectStore::open(path, true, HistoricObjectStoreMetrics::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, + None, + Some(HistoricRelocation { + store: historic, + supersession_epoch, + max_batch_bytes: usize::MAX, + }), + 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 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(tmp_dir.path()); + 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(tmp_dir.path()); + 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(tmp_dir.path()); + 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(tmp_dir.path()); + 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(tmp_dir.path()); + + 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(tmp_dir.path()); + 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); + } + // Tests pruning old version of live objects. #[tokio::test] async fn test_pruning_objects() { @@ -1301,9 +1885,15 @@ 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 total_pruned = AuthorityStorePruner::prune_objects( + vec![effects], + &perpetual_db, + None, + None, + 0, + metrics, + ) + .await; info!("Total pruned keys = {:?}", total_pruned); perpetual_db.objects.compact_range(&start, &end)?; @@ -1413,6 +2003,8 @@ mod tests { &checkpoint_store, None, None, + None, + usize::MAX, PruningMode::Checkpoints, num_epochs_to_retain, 0, diff --git a/crates/iota-core/src/authority/historic_object_store.rs b/crates/iota-core/src/authority/historic_object_store.rs new file mode 100644 index 000000000000..0f0014986259 --- /dev/null +++ b/crates/iota-core/src/authority/historic_object_store.rs @@ -0,0 +1,615 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Per-epoch storage for superseded object versions. +//! +//! When the live-object/historic split is enabled, the pruner relocates +//! superseded object versions into this store instead of deleting them. Rows +//! are bucketed by their *supersession epoch* (the epoch of the checkpoint +//! whose effects superseded them), one pair of column families per epoch, so +//! that expiring an epoch of history is a constant-time `drop_cf` instead of +//! per-key deletes. +//! +//! The store is strictly outside the consensus/execution read paths: the only +//! reader is the gRPC exact-version object lookup. 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, PathBuf}, + sync::{Arc, RwLock}, + time::Duration, +}; + +use iota_types::{ + base_types::EpochId, + error::{IotaError, IotaResult}, + object::Object, + storage::ObjectKey, +}; +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}; +use typed_store::{ + Map, + database::Database, + metrics::SamplingInterval, + rocks::{ + DBMap, DBOptions, MetricConf, ReadWriteOptions, default_db_options, list_tables, + read_size_from_env, + }, + rocksdb, +}; + +use crate::authority::authority_store_types::{StoreObject, StoreObjectWrapper}; + +const HISTORY_DIR_NAME: &str = "history"; +const META_CF_NAME: &str = "meta"; +const OBJECTS_CF_PREFIX: &str = "hist_obj_e"; +const EXPIRY_CF_PREFIX: &str = "hist_exp_e"; + +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, +} + +struct EpochBucket { + /// 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, +} + +pub struct HistoricObjectStoreMetrics { + pub relocated_objects: IntCounter, + pub relocated_bytes: IntCounter, + pub lookup_probes: Histogram, + pub lookup_not_found: IntCounter, + pub epochs_retained: IntGauge, + pub earliest_retained_epoch: IntGauge, +} + +impl HistoricObjectStoreMetrics { + pub fn new(registry: &Registry) -> Arc { + Arc::new(Self { + relocated_objects: register_int_counter_with_registry!( + "historic_object_store_relocated_objects", + "Number of superseded object versions relocated into the historic store", + registry + ) + .unwrap(), + relocated_bytes: register_int_counter_with_registry!( + "historic_object_store_relocated_bytes", + "Serialized bytes relocated into the historic store", + registry + ) + .unwrap(), + lookup_probes: register_histogram_with_registry!( + "historic_object_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_object_store_lookup_not_found", + "Historic lookups that missed every epoch bucket", + registry + ) + .unwrap(), + epochs_retained: register_int_gauge_with_registry!( + "historic_object_store_epochs_retained", + "Number of epoch buckets currently retained", + registry + ) + .unwrap(), + earliest_retained_epoch: register_int_gauge_with_registry!( + "historic_object_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, bucketed by supersession epoch. +/// +/// Writes come exclusively from the single pruner task; reads may come from +/// any number of RPC threads concurrently. +pub struct HistoricObjectStore { + 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>, + disable_wal: bool, + metrics: Arc, +} + +impl HistoricObjectStore { + pub fn path(parent_path: &Path) -> PathBuf { + parent_path.join(HISTORY_DIR_NAME) + } + + /// Opens (or creates) the store under `/history`, + /// rediscovering all per-epoch column families present on disk. + /// + /// Relocation batches are written without the WAL when `disable_wal` is + /// set: relocation is idempotent and re-runnable from the pruner + /// watermark, and the pruner flushes the bucket before deleting the + /// source rows, so durability is preserved. + pub fn open( + parent_path: &Path, + disable_wal: bool, + metrics: Arc, + ) -> IotaResult { + let path = Self::path(parent_path); + let db_options = default_db_options().disable_write_throttling(); + let cf_options = Self::epoch_cf_options(&db_options); + let meta_options = db_options.clone().optimize_for_point_lookup(8); + + // Column families must be passed at open with their tuned options; + // any column family left for auto-discovery would silently get + // default options (and its own block cache). + let existing_cfs = list_tables(path.clone()).unwrap_or_default(); + let mut opt_cfs: Vec<(&str, rocksdb::Options)> = vec![(META_CF_NAME, meta_options.options)]; + for cf_name in &existing_cfs { + if cf_name != META_CF_NAME { + opt_cfs.push((cf_name, cf_options.clone())); + } + } + + let db = typed_store::rocks::open_cf_opts( + &path, + Some(db_options.options), + MetricConf::new("history") + .with_sampling(SamplingInterval::new(Duration::from_secs(60), 0)), + &opt_cfs, + )?; + + let meta = DBMap::reopen(&db, Some(META_CF_NAME), &ReadWriteOptions::default(), false)?; + + // Column family names on disk 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 two + // column families are created and dropped in separate operations, so + // a crash can leave one of the pair missing: recreate it (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 &existing_cfs { + let epoch_str = match ( + cf_name.strip_prefix(OBJECTS_CF_PREFIX), + cf_name.strip_prefix(EXPIRY_CF_PREFIX), + ) { + (Some(epoch_str), _) | (_, Some(epoch_str)) => epoch_str, + (None, None) => 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::objects_cf_name(epoch), Self::expiry_cf_name(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, epoch)?); + } + + let store = Self { + db, + cf_options, + meta, + buckets: RwLock::new(buckets), + disable_wal, + 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 reopen_bucket(db: &Arc, 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. + let objects = DBMap::reopen( + db, + Some(&Self::objects_cf_name(epoch)), + &ReadWriteOptions::default(), + true, + )?; + let expiry = DBMap::reopen( + db, + Some(&Self::expiry_cf_name(epoch)), + &ReadWriteOptions::default(), + true, + )?; + Ok(EpochBucket { objects, expiry }) + } + + 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); + } + } + + /// Durably persists relocated rows and the tombstone-head expiry list + /// into the bucket for `supersession_epoch`, creating the bucket on first + /// use. Idempotent: rewriting the same keys with the same bytes is + /// harmless. + /// + /// Durability of the write is only guaranteed after a subsequent + /// [`Self::flush_epoch`]; callers must flush before deleting the source + /// rows from the live table. + 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.ensure_bucket(supersession_epoch)?; + let buckets = self.buckets.read().expect("lock should not be poisoned"); + let bucket = buckets + .get(&supersession_epoch) + .expect("bucket was just created"); + + let mut batch = bucket.objects.batch(); + batch.insert_batch(&bucket.objects, objects.iter().map(|(k, v)| (k, v)))?; + batch.insert_batch(&bucket.expiry, tombstone_heads.iter().map(|k| (k, ())))?; + + let mut info = self.meta.get(&supersession_epoch)?.unwrap_or_default(); + info.object_count += objects.len() as u64; + info.expiry_count += tombstone_heads.len() as u64; + batch.insert_batch(&self.meta, [(supersession_epoch, info)])?; + + let mut write_options = rocksdb::WriteOptions::default(); + write_options.disable_wal(self.disable_wal); + batch.write_opt(&write_options)?; + + 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(()) + } + + /// Flushes the bucket's memtables to disk. This is the durability barrier + /// for WAL-less relocation writes: it must complete before the relocated + /// rows are deleted from the live table. + pub fn flush_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.objects.flush()?; + bucket.expiry.flush()?; + 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.objects.flush()?; + bucket.expiry.flush()?; + // Full-range manual compaction: object 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]; + bucket.objects.compact_range_raw( + &Self::objects_cf_name(epoch), + vec![], + full_range_end.clone(), + )?; + bucket.expiry.compact_range_raw( + &Self::expiry_cf_name(epoch), + vec![], + full_range_end, + )?; + } + let mut info = self.meta.get(&epoch)?.unwrap_or_default(); + if !info.sealed { + info.sealed = true; + self.meta.insert(&epoch, &info)?; + } + Ok(()) + } + + /// Exact-key lookup with no epoch hint: probes buckets newest to oldest. + pub fn get_store_object(&self, key: &ObjectKey) -> IotaResult> { + 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(wrapper) = bucket.objects.get(key)? { + self.metrics.lookup_probes.observe(probes as f64); + return Ok(Some(wrapper)); + } + } + self.metrics.lookup_probes.observe(probes.max(1) as f64); + self.metrics.lookup_not_found.inc(); + 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() { + self.db + .drop_cf(&Self::objects_cf_name(epoch)) + .map_err(|e| IotaError::Storage(e.to_string()))?; + self.db + .drop_cf(&Self::expiry_cf_name(epoch)) + .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::objects_cf_name(epoch), Self::expiry_cf_name(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, 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) -> HistoricObjectStore { + HistoricObjectStore::open(path, true, HistoricObjectStoreMetrics::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.flush_epoch(7).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 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..2b56d8e31c4e 100644 --- a/crates/iota-core/src/authority/test_authority_builder.rs +++ b/crates/iota-core/src/authority/test_authority_builder.rs @@ -389,6 +389,7 @@ impl<'a> TestAuthorityBuilder<'a> { chain_identifier, pruner_db, None, + None, policy_config, firewall_config, ) diff --git a/crates/iota-core/src/db_checkpoint_handler.rs b/crates/iota-core/src/db_checkpoint_handler.rs index dbc7b04d788f..b0b17a040c49 100644 --- a/crates/iota-core/src/db_checkpoint_handler.rs +++ b/crates/iota-core/src/db_checkpoint_handler.rs @@ -287,11 +287,14 @@ impl DBCheckpointHandler { "Pruning db checkpoint in {:?} for epoch: {epoch}", db_path.display() ); + // Relocation must stay disabled here: this prunes a DB checkpoint + // snapshot, which contains no historic store to relocate into. AuthorityStorePruner::prune_objects_for_eligible_epochs( &perpetual_db, &checkpoint_store, Some(&grpc_indexes_store), None, + None, self.pruning_config.clone(), metrics, epoch_duration_ms, diff --git a/crates/iota-core/src/storage.rs b/crates/iota-core/src/storage.rs index 57375e05a540..efaae1f429a6 100644 --- a/crates/iota-core/src/storage.rs +++ b/crates/iota-core/src/storage.rs @@ -382,7 +382,19 @@ 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. + let Some(historic_store) = self.state.historic_object_store.as_ref() else { + return Ok(None); + }; + historic_store + .get_object(&ObjectKey(*object_id, version)) + .map_err(StorageError::custom) } } @@ -491,13 +503,33 @@ 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); + // With the historic store enabled, exact-version availability extends + // back to the start of the earliest retained epoch bucket. + let Some(earliest_epoch) = self + .state + .historic_object_store + .as_ref() + .and_then(|store| 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-node/src/lib.rs b/crates/iota-node/src/lib.rs index 159ef8443a34..920ab1363249 100644 --- a/crates/iota-node/src/lib.rs +++ b/crates/iota-node/src/lib.rs @@ -40,6 +40,7 @@ use iota_core::{ }, backpressure::BackpressureManager, epoch_start_configuration::{EpochFlag, EpochStartConfigTrait, EpochStartConfiguration}, + historic_object_store::{HistoricObjectStore, HistoricObjectStoreMetrics}, }, authority_aggregator::{ AggregatorSendCapabilityNotificationError, AuthAggMetrics, AuthorityAggregator, @@ -422,10 +423,46 @@ impl IotaNode { None, )); + let mut historic_object_store_config = config + .authority_store_pruning_config + .historic_object_store + .clone(); + if historic_object_store_config.is_some() { + // The compaction filter can only keep or remove rows at arbitrary + // compaction times; it would destroy rows before relocation could + // read them. This must be caught before the perpetual DB is + // opened, because the filter is installed at DB-open time. + anyhow::ensure!( + !config + .authority_store_pruning_config + .enable_compaction_filter, + "`historic-object-store` and `enable-compaction-filter` are mutually exclusive; \ + disable one of them" + ); + if is_validator { + warn!( + "The historic object store is fullnode-only; ignoring the configuration on \ + this validator." + ); + historic_object_store_config = None; + } + } + let historic_object_store = historic_object_store_config + .map(|historic_config| { + HistoricObjectStore::open( + &config.db_path().join("store"), + historic_config.disable_wal, + HistoricObjectStoreMetrics::new(&prometheus_registry), + ) + .map(Arc::new) + }) + .transpose()?; + let mut pruner_db = None; if config .authority_store_pruning_config .enable_compaction_filter + && historic_object_store.is_none() { pruner_db = Some(Arc::new(AuthorityPrunerTables::open( &config.db_path().join("store"), @@ -702,6 +739,7 @@ impl IotaNode { validator_tx_finalizer, chain_identifier, pruner_db, + historic_object_store, Some(checkpoint_progress_tracker.clone()), config.policy_config.clone(), config.firewall_config.clone(), diff --git a/crates/iota-tool/src/db_tool/db_dump.rs b/crates/iota-tool/src/db_tool/db_dump.rs index d1fd663b0b8a..ceddaca53b6c 100644 --- a/crates/iota-tool/src/db_tool/db_dump.rs +++ b/crates/iota-tool/src/db_tool/db_dump.rs @@ -229,6 +229,7 @@ pub async fn prune_objects(db_path: PathBuf) -> anyhow::Result<()> { &checkpoint_store, Some(&grpc_indexes_store), None, + None, pruning_config, metrics, EPOCH_DURATION_MS_FOR_TESTING, diff --git a/crates/typed-store/src/database.rs b/crates/typed-store/src/database.rs index 95e7fad86553..521f89fda717 100644 --- a/crates/typed-store/src/database.rs +++ b/crates/typed-store/src/database.rs @@ -227,9 +227,35 @@ impl Database { } } + /// 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 +367,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 +523,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 +531,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 +573,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 +596,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( From 8b71570b3ad621bf82b5a091a2e4dffffb403bae Mon Sep 17 00:00:00 2001 From: muXxer Date: Fri, 3 Jul 2026 12:27:17 +0200 Subject: [PATCH 02/12] feat(core): relocate checkpoint-keyed history into per-epoch buckets --- crates/iota-config/src/node.rs | 8 +- crates/iota-core/src/authority.rs | 13 +- .../src/authority/authority_store_pruner.rs | 519 ++++++++++++++---- ...oric_object_store.rs => historic_store.rs} | 462 +++++++++++++--- crates/iota-core/src/storage.rs | 160 +++++- crates/iota-node/src/lib.rs | 25 +- crates/iota-tool/src/db_tool/db_dump.rs | 1 + crates/iota-tool/src/lib.rs | 8 +- 8 files changed, 977 insertions(+), 219 deletions(-) rename crates/iota-core/src/authority/{historic_object_store.rs => historic_store.rs} (55%) diff --git a/crates/iota-config/src/node.rs b/crates/iota-config/src/node.rs index 0a2671af2eb0..618e691153a8 100644 --- a/crates/iota-config/src/node.rs +++ b/crates/iota-config/src/node.rs @@ -1065,12 +1065,12 @@ pub struct AuthorityStorePruningConfig { /// once out of retention. Fullnode-only; incompatible with /// `enable_compaction_filter`. #[serde(default, skip_serializing_if = "Option::is_none")] - pub historic_object_store: Option, + pub historic_store: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] -pub struct HistoricObjectStoreConfig { +pub struct HistoricStoreConfig { /// Number of epochs of superseded object versions to retain, bucketed by /// the epoch in which they were superseded. Whole epoch buckets are /// dropped once they fall out of this window. @@ -1098,7 +1098,7 @@ fn default_historic_disable_wal() -> bool { true } -impl Default for HistoricObjectStoreConfig { +impl Default for HistoricStoreConfig { fn default() -> Self { Self { num_epochs_to_retain: default_historic_epochs_to_retain(), @@ -1125,7 +1125,7 @@ impl Default for AuthorityStorePruningConfig { 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_object_store: None, + historic_store: None, } } } diff --git a/crates/iota-core/src/authority.rs b/crates/iota-core/src/authority.rs index 808678207736..b33dfed5f049 100644 --- a/crates/iota-core/src/authority.rs +++ b/crates/iota-core/src/authority.rs @@ -159,7 +159,7 @@ use crate::{ authority_store_pruner::{AuthorityStorePruner, EPOCH_DURATION_MS_FOR_TESTING}, authority_store_tables::AuthorityPrunerTables, epoch_start_configuration::{EpochStartConfigTrait, EpochStartConfiguration}, - historic_object_store::HistoricObjectStore, + historic_store::HistoricStore, }, authority_client::NetworkAuthorityClient, checkpoint_progress_tracker::CheckpointProgressTracker, @@ -234,7 +234,7 @@ pub mod authority_store_pruner; pub mod authority_store_tables; pub mod authority_store_types; pub mod epoch_start_configuration; -pub mod historic_object_store; +pub mod historic_store; pub mod shared_object_congestion_tracker; pub mod shared_object_version_manager; pub mod suggested_gas_price_calculator; @@ -858,7 +858,7 @@ pub struct AuthorityState { /// 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_object_store: Option>, + pub historic_store: Option>, pub subscription_handler: Arc, pub checkpoint_store: Arc, @@ -3266,7 +3266,7 @@ impl AuthorityState { validator_tx_finalizer: Option>>, chain_identifier: ChainIdentifier, pruner_db: Option>, - historic_object_store: Option>, + historic_store: Option>, checkpoint_progress_tracker: Option>, policy_config: Option, firewall_config: Option, @@ -3304,7 +3304,7 @@ impl AuthorityState { prometheus_registry, archive_readers, pruner_db, - historic_object_store.clone(), + historic_store.clone(), checkpoint_progress_tracker.clone(), ); let input_loader = @@ -3334,7 +3334,7 @@ impl AuthorityState { execution_cache_trait_pointers, indexes, grpc_indexes_store, - historic_object_store, + historic_store, subscription_handler: Arc::new(SubscriptionHandler::new(prometheus_registry)), checkpoint_store, committee_store, @@ -3437,6 +3437,7 @@ impl AuthorityState { &self.checkpoint_store, self.grpc_indexes_store.as_deref(), None, + self.historic_store.as_ref(), config.authority_store_pruning_config, metrics, archive_readers, diff --git a/crates/iota-core/src/authority/authority_store_pruner.rs b/crates/iota-core/src/authority/authority_store_pruner.rs index 7edb7c1fc747..e5b1a57720e5 100644 --- a/crates/iota-core/src/authority/authority_store_pruner.rs +++ b/crates/iota-core/src/authority/authority_store_pruner.rs @@ -18,6 +18,7 @@ use iota_sdk_types::ObjectId; use iota_types::{ base_types::{SequenceNumber, VersionNumber}, committee::EpochId, + digests::TransactionDigest, effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt}, messages_checkpoint::{ CheckpointContents, CheckpointContentsExt, CheckpointDigest, CheckpointSequenceNumber, @@ -48,7 +49,7 @@ use super::authority_store_tables::{AuthorityPerpetualTables, AuthorityPrunerTab use crate::{ authority::{ authority_store_types::{StoreObject, StoreObjectWrapper}, - historic_object_store::HistoricObjectStore, + historic_store::HistoricStore, }, checkpoint_progress_tracker::CheckpointProgressTracker, checkpoints::{CheckpointStore, CheckpointWatermark}, @@ -239,7 +240,7 @@ pub enum PruningMode { /// deleting superseded object versions, move them into the historic store /// bucket of the epoch whose checkpoints superseded them. struct HistoricRelocation<'a> { - store: &'a Arc, + store: &'a Arc, supersession_epoch: EpochId, max_batch_bytes: usize, } @@ -428,15 +429,113 @@ impl AuthorityStorePruner { Ok(()) } + /// Copies one epoch-homogeneous batch of checkpoint-keyed history into + /// the historic bucket of the checkpoints' epoch and flushes it, so the + /// caller may commit the corresponding deletes afterwards. + /// + /// Rows already deleted by a previous run that crashed before committing + /// its deletes are skipped, which keeps replay idempotent: their historic + /// copies were already written. + fn relocate_checkpoint_data( + 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() + } + + 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)))) + }); + + 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 + .put_checkpoint_data(relocation.supersession_epoch, data)?; + // Durability barrier: the relocated rows must be on disk before the + // source rows disappear. + relocation + .store + .flush_epoch(relocation.supersession_epoch)?; + Ok(()) + } + /// Prunes checkpoint-related data from the `AuthorityStore`, including /// transaction effects, executed transactions, and checkpoint contents, /// 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. + /// + /// With `relocation` set, 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 still + /// 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: Option>, checkpoint_number: CheckpointSequenceNumber, checkpoints_to_prune: Vec, checkpoint_content_to_prune: Vec, @@ -451,6 +550,18 @@ impl AuthorityStorePruner { .flat_map(|content| content.iter().map(|tx| tx.transaction)) .collect(); + if let Some(relocation) = &relocation { + Self::relocate_checkpoint_data( + 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( @@ -518,7 +629,7 @@ impl AuthorityStorePruner { checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, pruner_db: Option<&Arc>, - historic_store: Option<&Arc>, + historic_store: Option<&Arc>, config: AuthorityStorePruningConfig, metrics: Arc, epoch_duration_ms: u64, @@ -541,10 +652,18 @@ impl AuthorityStorePruner { .get_highest_pruned_checkpoint()? .unwrap_or_default(); let max_relocation_batch_bytes = config - .historic_object_store + .historic_store .as_ref() .map(|c| c.max_relocation_batch_bytes) .unwrap_or(usize::MAX); + // 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, @@ -552,6 +671,7 @@ impl AuthorityStorePruner { pruner_db, historic_store, max_relocation_batch_bytes, + seals_buckets, PruningMode::Objects, config.num_epochs_to_retain, pruned_checkpoint_number, @@ -576,6 +696,7 @@ impl AuthorityStorePruner { checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, pruner_db: Option<&Arc>, + historic_store: Option<&Arc>, config: AuthorityStorePruningConfig, metrics: Arc, archive_readers: ArchiveReaderBalancer, @@ -609,13 +730,21 @@ impl AuthorityStorePruner { let cutoff_timestamp_ms = last_executed_timestamp_ms .saturating_sub(num_epochs_to_retain.saturating_mul(epoch_duration_ms)); debug!("Max eligible checkpoint {}", max_eligible_checkpoint); + let max_relocation_batch_bytes = config + .historic_store + .as_ref() + .map(|c| c.max_relocation_batch_bytes) + .unwrap_or(usize::MAX); Self::prune_for_eligible_epochs( perpetual_db, checkpoint_store, grpc_indexes_store, pruner_db, - None, - usize::MAX, + historic_store, + max_relocation_batch_bytes, + // 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, @@ -634,8 +763,9 @@ impl AuthorityStorePruner { checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, pruner_db: Option<&Arc>, - historic_store: Option<&Arc>, + historic_store: Option<&Arc>, max_relocation_batch_bytes: usize, + seals_buckets: bool, mode: PruningMode, num_epochs_to_retain: u64, starting_checkpoint_number: CheckpointSequenceNumber, @@ -646,12 +776,6 @@ impl AuthorityStorePruner { ) -> anyhow::Result<()> { let _scope = monitored_scope("PruneForEligibleEpochs"); - // Relocation only applies to object pruning. - let historic_store = match mode { - PruningMode::Objects => historic_store, - PruningMode::Checkpoints => None, - }; - let mut checkpoint_number = starting_checkpoint_number; let current_epoch = checkpoint_store .get_highest_executed_checkpoint()? @@ -689,36 +813,49 @@ impl AuthorityStorePruner { } // With relocation enabled a batch must not span epochs: relocated - // rows are bucketed by the epoch of the checkpoint that superseded - // them. Flush the pending batch before crossing the boundary and - // seal the finished epoch's bucket. + // rows are bucketed by the epoch of their checkpoint. Flush the + // pending batch before crossing the boundary and seal the + // finished epoch's bucket. if let Some(store) = historic_store { match batch_epoch { Some(epoch) if epoch != checkpoint.epoch() => { if !checkpoints_to_prune.is_empty() { - Self::prune_objects( - std::mem::take(&mut effects_to_prune), + Self::prune_batch( perpetual_db, + checkpoint_store, + grpc_indexes_store, pruner_db, Some(HistoricRelocation { store, supersession_epoch: epoch, max_batch_bytes: max_relocation_batch_bytes, }), + 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?; - checkpoints_to_prune = vec![]; - checkpoint_content_to_prune = vec![]; if let Some(tracker) = progress_tracker { - tracker.add_object_pruning_time(pruning_start.elapsed()); + 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(); } } - store.seal_epoch(epoch)?; + if seals_buckets { + store.seal_epoch(epoch)?; + } } - None => { + None if seals_buckets => { // A previous run may have finished exactly at an // epoch boundary without sealing; catch up on any // unsealed earlier buckets. @@ -757,34 +894,25 @@ impl AuthorityStorePruner { if effects_to_prune.len() >= MAX_TRANSACTIONS_IN_BATCH || checkpoints_to_prune.len() >= MAX_CHECKPOINTS_IN_BATCH { - match mode { - PruningMode::Objects => { - Self::prune_objects( - effects_to_prune, - perpetual_db, - pruner_db, - historic_store.map(|store| HistoricRelocation { - store, - supersession_epoch: batch_epoch - .expect("batch epoch is set before batching"), - max_batch_bytes: max_relocation_batch_bytes, - }), - 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(), - )?, - }; + Self::prune_batch( + perpetual_db, + checkpoint_store, + grpc_indexes_store, + pruner_db, + historic_store.map(|store| HistoricRelocation { + store, + supersession_epoch: batch_epoch + .expect("batch epoch is set before batching"), + max_batch_bytes: max_relocation_batch_bytes, + }), + 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?; // Report pruning time for this batch so the progress logger // shows time alongside the checkpoint deltas it reads from the @@ -798,43 +926,30 @@ 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() { - match mode { - PruningMode::Objects => { - Self::prune_objects( - effects_to_prune, - perpetual_db, - pruner_db, - historic_store.map(|store| HistoricRelocation { - store, - supersession_epoch: batch_epoch - .expect("batch epoch is set before batching"), - max_batch_bytes: max_relocation_batch_bytes, - }), - 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(), - )?, - }; + Self::prune_batch( + perpetual_db, + checkpoint_store, + grpc_indexes_store, + pruner_db, + historic_store.map(|store| HistoricRelocation { + store, + supersession_epoch: batch_epoch.expect("batch epoch is set before batching"), + max_batch_bytes: max_relocation_batch_bytes, + }), + mode, + checkpoint_number, + checkpoints_to_prune, + checkpoint_content_to_prune, + effects_to_prune, + metrics.clone(), + ) + .await?; // Report pruning time for this batch so the progress logger // shows time alongside the checkpoint deltas it reads from the @@ -851,6 +966,46 @@ impl AuthorityStorePruner { Ok(()) } + /// Dispatches one pruning batch to the mode-specific pruner. + async fn prune_batch( + perpetual_db: &Arc, + checkpoint_store: &Arc, + grpc_indexes_store: Option<&GrpcIndexesStore>, + pruner_db: Option<&Arc>, + relocation: Option>, + 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, + pruner_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, + ), + } + } + /// 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. @@ -861,7 +1016,7 @@ impl AuthorityStorePruner { /// expiry list and leak the heads in the live table forever. fn drop_expired_historic_epochs( perpetual_db: &Arc, - historic_store: &Arc, + historic_store: &Arc, current_epoch: EpochId, num_epochs_to_retain: u64, ) -> anyhow::Result<()> { @@ -974,7 +1129,7 @@ impl AuthorityStorePruner { grpc_indexes_store: Option>, jsonrpc_index: Option>, pruner_db: Option>, - historic_store: Option>, + historic_store: Option>, metrics: Arc, archive_readers: ArchiveReaderBalancer, progress_tracker: Option>, @@ -988,7 +1143,7 @@ impl AuthorityStorePruner { ); let historic_epochs_to_retain = config - .historic_object_store + .historic_store .as_ref() .map(|c| c.num_epochs_to_retain) .unwrap_or(u64::MAX); @@ -1120,6 +1275,7 @@ impl AuthorityStorePruner { &checkpoint_store, grpc_indexes_store.as_deref(), pruner_db.as_ref(), + historic_store.as_ref(), config.clone(), metrics.clone(), archive_readers.clone(), @@ -1178,7 +1334,7 @@ impl AuthorityStorePruner { registry: &Registry, archive_readers: ArchiveReaderBalancer, pruner_db: Option>, - mut historic_store: Option>, + mut historic_store: Option>, progress_tracker: Option>, ) -> Self { if pruning_config.num_epochs_to_retain > 0 && pruning_config.num_epochs_to_retain < u64::MAX @@ -1391,7 +1547,7 @@ mod tests { authority_store_pruner::AuthorityStorePruningMetrics, authority_store_tables::AuthorityPerpetualTables, authority_store_types::{StoreObject, StoreObjectWrapper, get_store_object}, - historic_object_store::{HistoricObjectStore, HistoricObjectStoreMetrics}, + historic_store::{HistoricStore, HistoricStoreMetrics}, }, checkpoints::CheckpointStore, }; @@ -1527,11 +1683,8 @@ mod tests { to_keep } - fn open_historic(path: &Path) -> Arc { - Arc::new( - HistoricObjectStore::open(path, true, HistoricObjectStoreMetrics::new_for_test()) - .unwrap(), - ) + fn open_historic(path: &Path) -> Arc { + Arc::new(HistoricStore::open(path, true, HistoricStoreMetrics::new_for_test()).unwrap()) } /// Builds effects with production shapes: superseded input versions land @@ -1582,7 +1735,7 @@ mod tests { async fn relocate( db: &Arc, - historic: &Arc, + historic: &Arc, supersession_epoch: u64, effects: TransactionEffects, checkpoint_number: u64, @@ -1805,6 +1958,185 @@ mod tests { 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(tmp_dir.path()); + + // 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, + Some(HistoricRelocation { + store: &historic, + supersession_epoch: committee.epoch, + max_batch_bytes: usize::MAX, + }), + 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)); + } + // Tests pruning old version of live objects. #[tokio::test] async fn test_pruning_objects() { @@ -2005,6 +2337,7 @@ mod tests { None, None, usize::MAX, + true, PruningMode::Checkpoints, num_epochs_to_retain, 0, diff --git a/crates/iota-core/src/authority/historic_object_store.rs b/crates/iota-core/src/authority/historic_store.rs similarity index 55% rename from crates/iota-core/src/authority/historic_object_store.rs rename to crates/iota-core/src/authority/historic_store.rs index 0f0014986259..d913d1499d6c 100644 --- a/crates/iota-core/src/authority/historic_object_store.rs +++ b/crates/iota-core/src/authority/historic_store.rs @@ -1,20 +1,27 @@ // Copyright (c) 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! Per-epoch storage for superseded object versions. +//! Per-epoch storage for pruned historic data. //! -//! When the live-object/historic split is enabled, the pruner relocates -//! superseded object versions into this store instead of deleting them. Rows -//! are bucketed by their *supersession epoch* (the epoch of the checkpoint -//! whose effects superseded them), one pair of column families per epoch, so -//! that expiring an epoch of history is a constant-time `drop_cf` instead of -//! per-key deletes. +//! When the live/historic split is enabled, the pruner relocates data into +//! this store instead of deleting it: //! -//! The store is strictly outside the consensus/execution read paths: the only -//! reader is the gRPC exact-version object lookup. 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. +//! - 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, @@ -25,15 +32,22 @@ use std::{ 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}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use typed_store::{ Map, database::Database, @@ -51,6 +65,28 @@ const HISTORY_DIR_NAME: &str = "history"; const META_CF_NAME: &str = "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; @@ -66,6 +102,15 @@ pub struct EpochBucketInfo { 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 { @@ -76,10 +121,68 @@ struct EpochBucket { /// 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(()) + } } -pub struct HistoricObjectStoreMetrics { +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, @@ -87,42 +190,49 @@ pub struct HistoricObjectStoreMetrics { pub earliest_retained_epoch: IntGauge, } -impl HistoricObjectStoreMetrics { +impl HistoricStoreMetrics { pub fn new(registry: &Registry) -> Arc { Arc::new(Self { relocated_objects: register_int_counter_with_registry!( - "historic_object_store_relocated_objects", + "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_object_store_relocated_bytes", - "Serialized bytes relocated into the historic store", + "historic_store_relocated_bytes", + "Serialized bytes of object versions relocated into the historic store", registry ) .unwrap(), lookup_probes: register_histogram_with_registry!( - "historic_object_store_lookup_probes", + "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_object_store_lookup_not_found", + "historic_store_lookup_not_found", "Historic lookups that missed every epoch bucket", registry ) .unwrap(), epochs_retained: register_int_gauge_with_registry!( - "historic_object_store_epochs_retained", + "historic_store_epochs_retained", "Number of epoch buckets currently retained", registry ) .unwrap(), earliest_retained_epoch: register_int_gauge_with_registry!( - "historic_object_store_earliest_retained_epoch", + "historic_store_earliest_retained_epoch", "Earliest epoch with a retained bucket", registry ) @@ -139,7 +249,7 @@ impl HistoricObjectStoreMetrics { /// /// Writes come exclusively from the single pruner task; reads may come from /// any number of RPC threads concurrently. -pub struct HistoricObjectStore { +pub struct HistoricStore { db: Arc, /// Template options for per-epoch column families. All clones share one /// block cache through the cloned table factory. @@ -147,10 +257,10 @@ pub struct HistoricObjectStore { meta: DBMap, buckets: RwLock>, disable_wal: bool, - metrics: Arc, + metrics: Arc, } -impl HistoricObjectStore { +impl HistoricStore { pub fn path(parent_path: &Path) -> PathBuf { parent_path.join(HISTORY_DIR_NAME) } @@ -165,7 +275,7 @@ impl HistoricObjectStore { pub fn open( parent_path: &Path, disable_wal: bool, - metrics: Arc, + metrics: Arc, ) -> IotaResult { let path = Self::path(parent_path); let db_options = default_db_options().disable_write_throttling(); @@ -195,19 +305,18 @@ impl HistoricObjectStore { // Column family names on disk 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 two + // 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 one of the pair missing: recreate it (empty) + // 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 &existing_cfs { - let epoch_str = match ( - cf_name.strip_prefix(OBJECTS_CF_PREFIX), - cf_name.strip_prefix(EXPIRY_CF_PREFIX), - ) { - (Some(epoch_str), _) | (_, Some(epoch_str)) => epoch_str, - (None, None) => continue, + 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}")) @@ -216,7 +325,7 @@ impl HistoricObjectStore { } let mut buckets = BTreeMap::new(); for epoch in epochs { - for cf_name in [Self::objects_cf_name(epoch), Self::expiry_cf_name(epoch)] { + 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()))?; @@ -263,23 +372,33 @@ impl HistoricObjectStore { 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, 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. - let objects = DBMap::reopen( - db, - Some(&Self::objects_cf_name(epoch)), - &ReadWriteOptions::default(), - true, - )?; - let expiry = DBMap::reopen( - db, - Some(&Self::expiry_cf_name(epoch)), - &ReadWriteOptions::default(), - true, - )?; - Ok(EpochBucket { objects, expiry }) + fn map(db: &Arc, cf_name: String) -> IotaResult> { + Ok(DBMap::reopen( + db, + Some(&cf_name), + &ReadWriteOptions::default(), + true, + )?) + } + Ok(EpochBucket { + 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) { @@ -335,6 +454,53 @@ impl HistoricObjectStore { Ok(()) } + /// Durably persists one epoch-homogeneous batch of checkpoint-keyed + /// history into the bucket for `epoch`, creating the bucket on first use. + /// Idempotent: rewriting the same keys with the same bytes is harmless. + /// + /// Durability of the write is only guaranteed after a subsequent + /// [`Self::flush_epoch`]; callers must flush before deleting the source + /// rows. + pub fn put_checkpoint_data( + &self, + epoch: EpochId, + data: CheckpointHistoryBatch, + ) -> IotaResult<()> { + if data.is_empty() { + return Ok(()); + } + self.ensure_bucket(epoch)?; + let buckets = self.buckets.read().expect("lock should not be poisoned"); + let bucket = buckets.get(&epoch).expect("bucket was just created"); + + let num_transactions = data.transactions.len() as u64; + let mut batch = bucket.transactions.batch(); + 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 mut info = self.meta.get(&epoch)?.unwrap_or_default(); + 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)); + } + batch.insert_batch(&self.meta, [(epoch, info)])?; + + let mut write_options = rocksdb::WriteOptions::default(); + write_options.disable_wal(self.disable_wal); + batch.write_opt(&write_options)?; + + self.metrics.relocated_transactions.inc_by(num_transactions); + Ok(()) + } + /// Flushes the bucket's memtables to disk. This is the durability barrier /// for WAL-less relocation writes: it must complete before the relocated /// rows are deleted from the live table. @@ -343,8 +509,7 @@ impl HistoricObjectStore { let Some(bucket) = buckets.get(&epoch) else { return Ok(()); }; - bucket.objects.flush()?; - bucket.expiry.flush()?; + bucket.flush_all()?; Ok(()) } @@ -357,22 +522,16 @@ impl HistoricObjectStore { let Some(bucket) = buckets.get(&epoch) else { return Ok(()); }; - bucket.objects.flush()?; - bucket.expiry.flush()?; - // Full-range manual compaction: object keys are 40-byte + 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]; - bucket.objects.compact_range_raw( - &Self::objects_cf_name(epoch), - vec![], - full_range_end.clone(), - )?; - bucket.expiry.compact_range_raw( - &Self::expiry_cf_name(epoch), - vec![], - full_range_end, - )?; + for cf_name in Self::epoch_cf_names(epoch) { + bucket + .objects + .compact_range_raw(&cf_name, vec![], full_range_end.clone())?; + } } let mut info = self.meta.get(&epoch)?.unwrap_or_default(); if !info.sealed { @@ -383,14 +542,22 @@ impl HistoricObjectStore { } /// Exact-key lookup with no epoch hint: probes buckets newest to oldest. - pub fn get_store_object(&self, key: &ObjectKey) -> IotaResult> { + 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(wrapper) = bucket.objects.get(key)? { + if let Some(value) = select(bucket).get(key)? { self.metrics.lookup_probes.observe(probes as f64); - return Ok(Some(wrapper)); + return Ok(Some(value)); } } self.metrics.lookup_probes.observe(probes.max(1) as f64); @@ -398,6 +565,70 @@ impl HistoricObjectStore { 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. @@ -435,12 +666,11 @@ impl HistoricObjectStore { buckets.remove(&epoch) }; if removed.is_some() { - self.db - .drop_cf(&Self::objects_cf_name(epoch)) - .map_err(|e| IotaError::Storage(e.to_string()))?; - self.db - .drop_cf(&Self::expiry_cf_name(epoch)) - .map_err(|e| IotaError::Storage(e.to_string()))?; + 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(); @@ -483,7 +713,7 @@ impl HistoricObjectStore { if buckets.contains_key(&epoch) { return Ok(()); } - for cf_name in [Self::objects_cf_name(epoch), Self::expiry_cf_name(epoch)] { + 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() { @@ -507,8 +737,8 @@ mod tests { use super::*; use crate::authority::authority_store_types::get_store_object; - fn open_store(path: &Path) -> HistoricObjectStore { - HistoricObjectStore::open(path, true, HistoricObjectStoreMetrics::new_for_test()).unwrap() + fn open_store(path: &Path) -> HistoricStore { + HistoricStore::open(path, true, HistoricStoreMetrics::new_for_test()).unwrap() } fn test_row(version: u64) -> (ObjectKey, StoreObjectWrapper) { @@ -597,6 +827,88 @@ mod tests { 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 tombstone_rows_read_as_none_objects() { let tmp_dir = iota_common::tempdir(); diff --git a/crates/iota-core/src/storage.rs b/crates/iota-core/src/storage.rs index efaae1f429a6..5d62d6d7fb65 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: Option>, // in memory checkpoint watermark sequence numbers highest_verified_checkpoint: Arc>>, highest_synced_checkpoint: Arc>>, @@ -48,16 +56,39 @@ impl RocksDbStore { cache_traits: ExecutionCacheTraitPointers, committee_store: Arc, checkpoint_store: Arc, + historic_store: Option>, ) -> 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)); + } + let Some(historic_store) = &self.historic_store else { + return Ok(None); + }; + 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 +105,20 @@ 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)); + } + let Some(historic_store) = &self.historic_store else { + return Ok(None); + }; + Ok(historic_store + .get_checkpoint_by_digest(digest) + .map_err(StorageError::custom)? + .map(Into::into)) } fn try_get_checkpoint_by_sequence_number( @@ -111,25 +153,48 @@ 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 + .as_ref() + .map(|store| store.lowest_available_checkpoint()) + .transpose() + .map_err(StorageError::custom)? + .flatten() + 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 +210,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 +254,46 @@ 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)); + } + let Some(historic_store) = &self.historic_store else { + return Ok(None); + }; + Ok(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(historic_store) = &self.historic_store else { + return Ok(None); + }; + let Some(effects_digest) = historic_store + .get_executed_effects(digest) + .map_err(StorageError::custom)? + else { + return Ok(None); + }; + historic_store + .get_effects(&effects_digest) .map_err(StorageError::custom) } @@ -213,9 +301,19 @@ 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)); + } + let Some(historic_store) = &self.historic_store else { + return Ok(None); + }; + historic_store + .get_events(digest) .map_err(StorageError::custom) } @@ -234,9 +332,19 @@ 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)); + } + let Some(historic_store) = &self.historic_store else { + return Ok(None); + }; + historic_store + .get_checkpoint_contents(digest) + .map_err(StorageError::custom) } fn try_get_checkpoint_contents_by_sequence_number( @@ -389,7 +497,7 @@ impl ObjectStore for GrpcReadStore { // constructed only for the gRPC server, so this fallback is // unreachable from consensus and execution: a live-table miss there // must stay a miss. - let Some(historic_store) = self.state.historic_object_store.as_ref() else { + let Some(historic_store) = self.state.historic_store.as_ref() else { return Ok(None); }; historic_store @@ -514,7 +622,7 @@ impl GrpcStateReader for GrpcReadStore { // back to the start of the earliest retained epoch bucket. let Some(earliest_epoch) = self .state - .historic_object_store + .historic_store .as_ref() .and_then(|store| store.earliest_epoch()) else { diff --git a/crates/iota-node/src/lib.rs b/crates/iota-node/src/lib.rs index 920ab1363249..e33a2a1de489 100644 --- a/crates/iota-node/src/lib.rs +++ b/crates/iota-node/src/lib.rs @@ -40,7 +40,7 @@ use iota_core::{ }, backpressure::BackpressureManager, epoch_start_configuration::{EpochFlag, EpochStartConfigTrait, EpochStartConfiguration}, - historic_object_store::{HistoricObjectStore, HistoricObjectStoreMetrics}, + historic_store::{HistoricStore, HistoricStoreMetrics}, }, authority_aggregator::{ AggregatorSendCapabilityNotificationError, AuthAggMetrics, AuthorityAggregator, @@ -423,11 +423,9 @@ impl IotaNode { None, )); - let mut historic_object_store_config = config - .authority_store_pruning_config - .historic_object_store - .clone(); - if historic_object_store_config.is_some() { + let mut historic_store_config = + config.authority_store_pruning_config.historic_store.clone(); + if historic_store_config.is_some() { // The compaction filter can only keep or remove rows at arbitrary // compaction times; it would destroy rows before relocation could // read them. This must be caught before the perpetual DB is @@ -436,7 +434,7 @@ impl IotaNode { !config .authority_store_pruning_config .enable_compaction_filter, - "`historic-object-store` and `enable-compaction-filter` are mutually exclusive; \ + "`historic-store` and `enable-compaction-filter` are mutually exclusive; \ disable one of them" ); if is_validator { @@ -444,15 +442,15 @@ impl IotaNode { "The historic object store is fullnode-only; ignoring the configuration on \ this validator." ); - historic_object_store_config = None; + historic_store_config = None; } } - let historic_object_store = historic_object_store_config + let historic_store = historic_store_config .map(|historic_config| { - HistoricObjectStore::open( + HistoricStore::open( &config.db_path().join("store"), historic_config.disable_wal, - HistoricObjectStoreMetrics::new(&prometheus_registry), + HistoricStoreMetrics::new(&prometheus_registry), ) .map(Arc::new) }) @@ -462,7 +460,7 @@ impl IotaNode { if config .authority_store_pruning_config .enable_compaction_filter - && historic_object_store.is_none() + && historic_store.is_none() { pruner_db = Some(Arc::new(AuthorityPrunerTables::open( &config.db_path().join("store"), @@ -608,6 +606,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 { @@ -739,7 +738,7 @@ impl IotaNode { validator_tx_finalizer, chain_identifier, pruner_db, - historic_object_store, + historic_store, Some(checkpoint_progress_tracker.clone()), config.policy_config.clone(), config.firewall_config.clone(), diff --git a/crates/iota-tool/src/db_tool/db_dump.rs b/crates/iota-tool/src/db_tool/db_dump.rs index ceddaca53b6c..f53af27cace9 100644 --- a/crates/iota-tool/src/db_tool/db_dump.rs +++ b/crates/iota-tool/src/db_tool/db_dump.rs @@ -256,6 +256,7 @@ pub async fn prune_checkpoints(db_path: PathBuf) -> anyhow::Result<()> { &checkpoint_store, Some(&grpc_indexes_store), None, + None, pruning_config, metrics, archive_readers, diff --git a/crates/iota-tool/src/lib.rs b/crates/iota-tool/src/lib.rs index 2ae3f463039c..c9793d471fe3 100644 --- a/crates/iota-tool/src/lib.rs +++ b/crates/iota-tool/src/lib.rs @@ -644,8 +644,12 @@ pub async fn backfill_checkpoint_summaries( let checkpoint_store = CheckpointStore::new(&node_db_path.join("checkpoints")); let store = AuthorityStore::open_no_genesis(perpetual_db, false, &Registry::default())?; 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(), + None, + ); let highest_synced = checkpoint_store .get_highest_synced_checkpoint()? From 48c9c9c88f30cf19d7e5ce4f89b0f9a5a539fee6 Mon Sep 17 00:00:00 2001 From: muXxer Date: Sat, 11 Jul 2026 16:11:12 +0200 Subject: [PATCH 03/12] feat(core): include the history DB in RocksDB DB checkpoints --- crates/iota-core/src/authority.rs | 17 +++-- .../src/authority/authority_store_pruner.rs | 53 ++++++++++++++++ .../iota-core/src/authority/historic_store.rs | 62 +++++++++++++++++++ crates/iota-core/src/db_checkpoint_handler.rs | 61 +++++++++++------- 4 files changed, 164 insertions(+), 29 deletions(-) diff --git a/crates/iota-core/src/authority.rs b/crates/iota-core/src/authority.rs index b33dfed5f049..8223aaf2dae5 100644 --- a/crates/iota-core/src/authority.rs +++ b/crates/iota-core/src/authority.rs @@ -3845,12 +3845,6 @@ impl AuthorityState { self.get_reconfig_api() .try_checkpoint_db(&store_checkpoint_path_tmp.join("perpetual"))?; - // The historic object store is intentionally not part of DB - // checkpoints: it only holds superseded object versions served over - // gRPC, can grow to terabytes, and a node restored without it stays - // fully consensus-consistent (it merely answers NotFound for - // relocated versions). - self.committee_store .checkpoint_db(&checkpoint_path_tmp.join("epochs"))?; @@ -3863,6 +3857,17 @@ impl AuthorityState { } } + // The history DB must be snapshotted after its source stores + // (perpetual and checkpoints): relocation writes history before + // deleting the source rows, so this ordering can at worst capture a + // harmless duplicate, while the reverse has a window where a row + // relocated in between is in neither snapshot. The restored layout + // (`store/history`) must match the live layout because restore is a + // plain directory copy. + if let Some(historic_store) = self.historic_store.as_ref() { + historic_store.checkpoint_db(&store_checkpoint_path_tmp.join("history"))?; + } + fs::rename(checkpoint_path_tmp, checkpoint_path) .map_err(|e| IotaError::FileIO(e.to_string()))?; Ok(()) diff --git a/crates/iota-core/src/authority/authority_store_pruner.rs b/crates/iota-core/src/authority/authority_store_pruner.rs index e5b1a57720e5..98536a933e05 100644 --- a/crates/iota-core/src/authority/authority_store_pruner.rs +++ b/crates/iota-core/src/authority/authority_store_pruner.rs @@ -2137,6 +2137,59 @@ mod tests { assert_eq!(historic.lowest_available_checkpoint().unwrap(), Some(9)); } + /// DB checkpoints must snapshot the source stores before the history DB: + /// relocation writes history before deleting the source rows, so with + /// that ordering a row relocated between the two snapshots is always in + /// at least one of them. This test relocates in between the snapshots + /// (the torn window) and asserts the restored copies jointly cover every + /// row. + #[tokio::test] + async fn db_checkpoint_source_first_ordering_loses_no_rows() { + let tmp_dir = iota_common::tempdir(); + let db = Arc::new(AuthorityPerpetualTables::open(tmp_dir.path(), None)); + let historic = open_historic(tmp_dir.path()); + 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; + + // Snapshot the source store first ... + let restore_dir = iota_common::tempdir(); + db.objects + .checkpoint_db(&restore_dir.path().join("perpetual")) + .unwrap(); + // ... relocation continues in the torn window ... + relocate(&db, &historic, 1, effects_superseding(second_half, &[]), 2).await; + // ... and the history DB is snapshotted last. + historic + .checkpoint_db(&restore_dir.path().join("history")) + .unwrap(); + + let restored_db = Arc::new(AuthorityPerpetualTables::open(restore_dir.path(), None)); + let restored_historic = open_historic(restore_dir.path()); + 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:?} is in neither restored store" + ); + } + // 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() { diff --git a/crates/iota-core/src/authority/historic_store.rs b/crates/iota-core/src/authority/historic_store.rs index d913d1499d6c..86792984fad4 100644 --- a/crates/iota-core/src/authority/historic_store.rs +++ b/crates/iota-core/src/authority/historic_store.rs @@ -702,6 +702,19 @@ impl HistoricStore { Ok(self.meta.get(&epoch)?.is_some_and(|info| info.sealed)) } + /// Takes a RocksDB checkpoint of the whole history DB (all epoch column + /// families) at `path`. + /// + /// Callers snapshotting multiple stores must snapshot the *source* + /// stores (perpetual, checkpoints) before this one: relocation writes + /// history before deleting the source rows, so source-first ordering + /// can at worst capture a harmless duplicate, while history-first has a + /// window where a row relocated in between is in neither snapshot. + pub fn checkpoint_db(&self, path: &Path) -> IotaResult<()> { + // Checkpointing any map snapshots the whole database. + self.meta.checkpoint_db(path).map_err(Into::into) + } + fn ensure_bucket(&self, epoch: EpochId) -> IotaResult<()> { { let buckets = self.buckets.read().expect("lock should not be poisoned"); @@ -909,6 +922,55 @@ mod tests { 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(); + + let copy_dir = iota_common::tempdir(); + store + .checkpoint_db(©_dir.path().join(HISTORY_DIR_NAME)) + .unwrap(); + + let copy = open_store(copy_dir.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(); diff --git a/crates/iota-core/src/db_checkpoint_handler.rs b/crates/iota-core/src/db_checkpoint_handler.rs index b0b17a040c49..ad502954ce88 100644 --- a/crates/iota-core/src/db_checkpoint_handler.rs +++ b/crates/iota-core/src/db_checkpoint_handler.rs @@ -278,29 +278,44 @@ impl DBCheckpointHandler { epoch_duration_ms: u64, ) -> Result<()> { 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() - ); - // Relocation must stay disabled here: this prunes a DB checkpoint - // snapshot, which contains no historic store to relocate into. - AuthorityStorePruner::prune_objects_for_eligible_epochs( - &perpetual_db, - &checkpoint_store, - Some(&grpc_indexes_store), - None, - None, - self.pruning_config.clone(), - metrics, - epoch_duration_ms, - self.checkpoint_progress_tracker.as_ref(), - ) - .await?; + if self.pruning_config.historic_store.is_some() { + // With the historic store enabled the source node prunes + // continuously by relocation, so there is little to shrink here — + // and delete-mode pruning of the snapshot would strip data from + // its perpetual store without relocating it into the snapshot's + // history, making the uploaded artifact serve less history than + // the source node. Only compact. + info!( + "Skipping pruning of db checkpoint in {:?} for epoch: {epoch}: the historic \ + store keeps the source continuously pruned", + db_path.display() + ); + } else { + 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() + ); + // Relocation must stay disabled here: this prunes a DB checkpoint + // snapshot, which contains no historic store to relocate into. + AuthorityStorePruner::prune_objects_for_eligible_epochs( + &perpetual_db, + &checkpoint_store, + Some(&grpc_indexes_store), + None, + 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() From 5210a2e7d777c8dac11e0f83b00c5281651ee6e6 Mon Sep 17 00:00:00 2001 From: muXxer Date: Sat, 11 Jul 2026 19:10:08 +0200 Subject: [PATCH 04/12] feat(core): relocate superseded versions at checkpoint-commit time in one atomic batch --- crates/iota-config/src/node.rs | 18 -- crates/iota-core/src/authority.rs | 14 +- .../src/authority/authority_store.rs | 50 +++- .../src/authority/authority_store_pruner.rs | 243 ++++++++++-------- .../src/authority/authority_store_tables.rs | 20 +- .../iota-core/src/authority/historic_store.rs | 236 +++++++++-------- .../data_ingestion_handler.rs | 86 ++++++- .../checkpoints/checkpoint_executor/mod.rs | 2 + crates/iota-core/src/execution_cache.rs | 12 + .../unit_tests/writeback_cache_tests.rs | 1 + .../src/execution_cache/writeback_cache.rs | 10 + crates/iota-core/src/transaction_outputs.rs | 28 ++ crates/iota-node/src/lib.rs | 34 ++- crates/iota-tool/src/lib.rs | 4 +- crates/typed-store-derive/src/lib.rs | 30 ++- crates/typed-store/src/database.rs | 13 + 16 files changed, 528 insertions(+), 273 deletions(-) diff --git a/crates/iota-config/src/node.rs b/crates/iota-config/src/node.rs index 618e691153a8..ca72820fe5d6 100644 --- a/crates/iota-config/src/node.rs +++ b/crates/iota-config/src/node.rs @@ -1076,34 +1076,16 @@ pub struct HistoricStoreConfig { /// dropped once they fall out of this window. #[serde(default = "default_historic_epochs_to_retain")] pub num_epochs_to_retain: u64, - /// Byte budget for a single relocation write batch. - #[serde(default = "default_max_relocation_batch_bytes")] - pub max_relocation_batch_bytes: usize, - /// Skip the write-ahead log for relocation writes. Safe because - /// relocation is idempotent and flushed before the source rows are - /// deleted; disable only for debugging. - #[serde(default = "default_historic_disable_wal")] - pub disable_wal: bool, } fn default_historic_epochs_to_retain() -> u64 { 100 } -fn default_max_relocation_batch_bytes() -> usize { - 256 * 1024 * 1024 -} - -fn default_historic_disable_wal() -> bool { - true -} - impl Default for HistoricStoreConfig { fn default() -> Self { Self { num_epochs_to_retain: default_historic_epochs_to_retain(), - max_relocation_batch_bytes: default_max_relocation_batch_bytes(), - disable_wal: default_historic_disable_wal(), } } } diff --git a/crates/iota-core/src/authority.rs b/crates/iota-core/src/authority.rs index 8223aaf2dae5..26787fd1065b 100644 --- a/crates/iota-core/src/authority.rs +++ b/crates/iota-core/src/authority.rs @@ -1731,6 +1731,7 @@ impl AuthorityState { transaction.clone().into_unsigned(), effects.clone(), inner_temporary_store, + self.historic_store.is_some(), ); self.get_cache_writer() .try_write_transaction_outputs(epoch_store.epoch(), transaction_outputs.into())?; @@ -3857,16 +3858,9 @@ impl AuthorityState { } } - // The history DB must be snapshotted after its source stores - // (perpetual and checkpoints): relocation writes history before - // deleting the source rows, so this ordering can at worst capture a - // harmless duplicate, while the reverse has a window where a row - // relocated in between is in neither snapshot. The restored layout - // (`store/history`) must match the live layout because restore is a - // plain directory copy. - if let Some(historic_store) = self.historic_store.as_ref() { - historic_store.checkpoint_db(&store_checkpoint_path_tmp.join("history"))?; - } + // 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()))?; diff --git a/crates/iota-core/src/authority/authority_store.rs b/crates/iota-core/src/authority/authority_store.rs index 2c232c9c64ff..c664ae66e653 100644 --- a/crates/iota-core/src/authority/authority_store.rs +++ b/crates/iota-core/src/authority/authority_store.rs @@ -50,6 +50,7 @@ use crate::{ authority_store_tables::TotalIotaSupplyCheck, authority_store_types::{StoreObject, StoreObjectWrapper, get_store_object}, epoch_start_configuration::{EpochFlag, EpochStartConfiguration}, + historic_store::HistoricStore, }, global_state_hasher::GlobalStateHashStore, grpc_indexes::GrpcIndexesStore, @@ -125,6 +126,11 @@ pub struct AuthorityStore { pub(crate) perpetual_tables: Arc, + /// When set, 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: Option>, + pub(crate) root_state_notify_read: NotifyRead, @@ -146,6 +152,7 @@ impl AuthorityStore { config: &NodeConfig, registry: &Registry, migration_tx_data: Option<&MigrationTxData>, + historic_store: Option>, ) -> IotaResult> { let enable_epoch_iota_conservation_check = config .expensive_safety_check_config @@ -185,6 +192,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 +239,15 @@ 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 + Self::open_inner( + genesis, + perpetual_tables, + true, + &Registry::new(), + None, + None, + ) + .await } async fn open_inner( @@ -240,10 +256,12 @@ impl AuthorityStore { enable_epoch_iota_conservation_check: bool, registry: &Registry, migration_tx_data: Option<&MigrationTxData>, + historic_store: Option>, ) -> IotaResult> { let store = Arc::new(Self { mutex_table: MutexTable::new(NUM_SHARDS), perpetual_tables, + historic_store, root_state_notify_read: NotifyRead::< EpochId, (CheckpointSequenceNumber, GlobalStateHash), @@ -367,10 +385,12 @@ impl AuthorityStore { perpetual_tables: Arc, enable_epoch_iota_conservation_check: bool, registry: &Registry, + historic_store: Option>, ) -> 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 +849,13 @@ 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. + if let Some(historic_store) = &self.historic_store { + historic_store.prepare_bucket(epoch_id)?; + } + let mut write_batch = self.perpetual_tables.transactions.batch(); for outputs in tx_outputs { self.write_one_transaction_outputs( @@ -912,6 +939,27 @@ impl AuthorityStore { write_batch.insert_batch(&self.perpetual_tables.objects, new_objects)?; + // With the historic store enabled, 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. + if let Some(historic_store) = &self.historic_store { + 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(); + 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( diff --git a/crates/iota-core/src/authority/authority_store_pruner.rs b/crates/iota-core/src/authority/authority_store_pruner.rs index 98536a933e05..5960a44c6e9f 100644 --- a/crates/iota-core/src/authority/authority_store_pruner.rs +++ b/crates/iota-core/src/authority/authority_store_pruner.rs @@ -242,7 +242,6 @@ pub enum PruningMode { struct HistoricRelocation<'a> { store: &'a Arc, supersession_epoch: EpochId, - max_batch_bytes: usize, } impl AuthorityStorePruner { @@ -288,7 +287,7 @@ impl AuthorityStorePruner { Self::relocate_objects( &mut wb, perpetual_db, - relocation, + &relocation, live_object_keys_to_prune, object_tombstones_to_prune, )?; @@ -359,14 +358,16 @@ impl AuthorityStorePruner { Ok(()) } - /// Moves superseded object versions into the historic store instead of - /// deleting them, and schedules the point deletes of the relocated keys - /// into `wb`. + /// 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). /// - /// The historic writes are flushed before this function returns, so the - /// caller may commit `wb` (which also advances the pruning watermark) - /// afterwards: on a crash in between, replay finds the not-yet-deleted - /// live rows and rewrites identical historic rows. + /// 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 @@ -379,64 +380,43 @@ impl AuthorityStorePruner { fn relocate_objects( wb: &mut DBBatch, perpetual_db: &Arc, - relocation: HistoricRelocation<'_>, + relocation: &HistoricRelocation<'_>, live_object_keys_to_prune: Vec, tombstone_heads: Vec, ) -> anyhow::Result<()> { - let HistoricRelocation { - store, - supersession_epoch, - max_batch_bytes, - } = relocation; - - // Keys already absent were relocated by a previous run that crashed - // before committing the deletes; skipping them keeps replay - // idempotent. let values = perpetual_db .objects .multi_get(live_object_keys_to_prune.iter())?; - let mut relocated_keys = Vec::with_capacity(live_object_keys_to_prune.len()); - let mut chunk = Vec::new(); - let mut chunk_bytes = 0usize; - let mut tombstone_heads = Some(tombstone_heads); - for (key, value) in live_object_keys_to_prune.into_iter().zip(values) { - let Some(value) = value else { - continue; - }; - relocated_keys.push(key); - chunk_bytes += bcs::serialized_size(&value)?; - chunk.push((key, value)); - if chunk_bytes >= max_batch_bytes { - store.put_objects( - supersession_epoch, - &chunk, - &tombstone_heads.take().unwrap_or_default(), - )?; - chunk.clear(); - chunk_bytes = 0; - } - } - store.put_objects( - supersession_epoch, - &chunk, - &tombstone_heads.take().unwrap_or_default(), - )?; - // Durability barrier: the relocated rows must be on disk before the - // live rows disappear. - store.flush_epoch(supersession_epoch)?; + let rows: Vec<_> = live_object_keys_to_prune + .into_iter() + .zip(values) + .filter_map(|(key, value)| value.map(|value| (key, value))) + .collect(); - wb.delete_batch(&perpetual_db.objects, relocated_keys)?; + 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(()) } - /// Copies one epoch-homogeneous batch of checkpoint-keyed history into - /// the historic bucket of the checkpoints' epoch and flushes it, so the - /// caller may commit the corresponding deletes afterwards. + /// 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 that crashed before committing - /// its deletes are skipped, which keeps replay idempotent: their historic - /// copies were already written. + /// 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<'_>, @@ -507,12 +487,12 @@ impl AuthorityStorePruner { }; relocation .store - .put_checkpoint_data(relocation.supersession_epoch, data)?; - // Durability barrier: the relocated rows must be on disk before the - // source rows disappear. - relocation - .store - .flush_epoch(relocation.supersession_epoch)?; + .prepare_bucket(relocation.supersession_epoch)?; + relocation.store.stage_checkpoint_data( + perpetual_batch, + relocation.supersession_epoch, + data, + )?; Ok(()) } @@ -552,6 +532,7 @@ impl AuthorityStorePruner { if let Some(relocation) = &relocation { Self::relocate_checkpoint_data( + &mut perpetual_batch, perpetual_db, checkpoint_db, relocation, @@ -651,11 +632,6 @@ impl AuthorityStorePruner { let pruned_checkpoint_number = perpetual_db .get_highest_pruned_checkpoint()? .unwrap_or_default(); - let max_relocation_batch_bytes = config - .historic_store - .as_ref() - .map(|c| c.max_relocation_batch_bytes) - .unwrap_or(usize::MAX); // 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 @@ -670,7 +646,6 @@ impl AuthorityStorePruner { grpc_indexes_store, pruner_db, historic_store, - max_relocation_batch_bytes, seals_buckets, PruningMode::Objects, config.num_epochs_to_retain, @@ -730,18 +705,12 @@ impl AuthorityStorePruner { let cutoff_timestamp_ms = last_executed_timestamp_ms .saturating_sub(num_epochs_to_retain.saturating_mul(epoch_duration_ms)); debug!("Max eligible checkpoint {}", max_eligible_checkpoint); - let max_relocation_batch_bytes = config - .historic_store - .as_ref() - .map(|c| c.max_relocation_batch_bytes) - .unwrap_or(usize::MAX); Self::prune_for_eligible_epochs( perpetual_db, checkpoint_store, grpc_indexes_store, pruner_db, historic_store, - max_relocation_batch_bytes, // The checkpoint pruner always seals: its eligibility is capped // at the objects watermark, so it is the lagging pruning mode. true, @@ -764,7 +733,6 @@ impl AuthorityStorePruner { grpc_indexes_store: Option<&GrpcIndexesStore>, pruner_db: Option<&Arc>, historic_store: Option<&Arc>, - max_relocation_batch_bytes: usize, seals_buckets: bool, mode: PruningMode, num_epochs_to_retain: u64, @@ -828,7 +796,6 @@ impl AuthorityStorePruner { Some(HistoricRelocation { store, supersession_epoch: epoch, - max_batch_bytes: max_relocation_batch_bytes, }), mode, checkpoint_number, @@ -903,7 +870,6 @@ impl AuthorityStorePruner { store, supersession_epoch: batch_epoch .expect("batch epoch is set before batching"), - max_batch_bytes: max_relocation_batch_bytes, }), mode, checkpoint_number, @@ -940,7 +906,6 @@ impl AuthorityStorePruner { historic_store.map(|store| HistoricRelocation { store, supersession_epoch: batch_epoch.expect("batch epoch is set before batching"), - max_batch_bytes: max_relocation_batch_bytes, }), mode, checkpoint_number, @@ -1683,8 +1648,10 @@ mod tests { to_keep } - fn open_historic(path: &Path) -> Arc { - Arc::new(HistoricStore::open(path, true, HistoricStoreMetrics::new_for_test()).unwrap()) + 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 @@ -1747,7 +1714,6 @@ mod tests { Some(HistoricRelocation { store: historic, supersession_epoch, - max_batch_bytes: usize::MAX, }), checkpoint_number, AuthorityStorePruningMetrics::new_for_test(), @@ -1763,11 +1729,85 @@ mod tests { /// 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(), + Some(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()); + assert_eq!(historic.list_epochs(), vec![3]); + 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(tmp_dir.path()); + 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; @@ -1793,7 +1833,7 @@ mod tests { 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(tmp_dir.path()); + let historic = open_historic(&db); let (_, to_delete, tombstones) = generate_test_data(db.clone(), 3, 0, 10).unwrap(); relocate( @@ -1832,7 +1872,7 @@ mod tests { 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(tmp_dir.path()); + 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 @@ -1863,7 +1903,7 @@ mod tests { 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(tmp_dir.path()); + let historic = open_historic(&db); let (_, to_delete, tombstones) = generate_test_data(db.clone(), 3, 0, 10).unwrap(); relocate( @@ -1912,7 +1952,7 @@ mod tests { 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(tmp_dir.path()); + let historic = open_historic(&db); let object = Object::immutable_with_id_for_testing(ObjectId::random()); let key = ObjectKey(object.id(), object.version()); @@ -1935,7 +1975,7 @@ mod tests { 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(tmp_dir.path()); + let historic = open_historic(&db); let (_, to_delete, _) = generate_test_data(db.clone(), 4, 1, 100).unwrap(); let live_set_before: Vec<_> = db @@ -1984,7 +2024,7 @@ mod tests { 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(tmp_dir.path()); + let historic = open_historic(&perpetual_db); // One transaction with effects and events, wired into one checkpoint. let transaction = VerifiedTransaction::new_genesis_transaction(vec![], vec![]); @@ -2059,7 +2099,6 @@ mod tests { Some(HistoricRelocation { store: &historic, supersession_epoch: committee.epoch, - max_batch_bytes: usize::MAX, }), 9, vec![ckpt_digest], @@ -2137,42 +2176,35 @@ mod tests { assert_eq!(historic.lowest_available_checkpoint().unwrap(), Some(9)); } - /// DB checkpoints must snapshot the source stores before the history DB: - /// relocation writes history before deleting the source rows, so with - /// that ordering a row relocated between the two snapshots is always in - /// at least one of them. This test relocates in between the snapshots - /// (the torn window) and asserts the restored copies jointly cover every - /// row. + /// 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_source_first_ordering_loses_no_rows() { + 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(tmp_dir.path()); + 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; - // Snapshot the source store first ... let restore_dir = iota_common::tempdir(); db.objects .checkpoint_db(&restore_dir.path().join("perpetual")) .unwrap(); - // ... relocation continues in the torn window ... + // Relocation after the snapshot must not affect the restored copy. relocate(&db, &historic, 1, effects_superseding(second_half, &[]), 2).await; - // ... and the history DB is snapshotted last. - historic - .checkpoint_db(&restore_dir.path().join("history")) - .unwrap(); let restored_db = Arc::new(AuthorityPerpetualTables::open(restore_dir.path(), None)); - let restored_historic = open_historic(restore_dir.path()); + 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:?} is in neither restored store" + in_live ^ in_history, + "{key:?} must be in exactly one restored table" ); } // Relocated data reads back as full objects from the restored copy, @@ -2389,7 +2421,6 @@ mod tests { None, None, None, - usize::MAX, true, PruningMode::Checkpoints, num_epochs_to_retain, diff --git a/crates/iota-core/src/authority/authority_store_tables.rs b/crates/iota-core/src/authority/authority_store_tables.rs index afb7ee9432ec..72c31af54c5a 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, @@ -45,6 +45,10 @@ 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 { @@ -198,7 +202,7 @@ 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), @@ -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), diff --git a/crates/iota-core/src/authority/historic_store.rs b/crates/iota-core/src/authority/historic_store.rs index 86792984fad4..5a4127202d38 100644 --- a/crates/iota-core/src/authority/historic_store.rs +++ b/crates/iota-core/src/authority/historic_store.rs @@ -25,9 +25,8 @@ use std::{ collections::BTreeMap, - path::{Path, PathBuf}, + path::Path, sync::{Arc, RwLock}, - time::Duration, }; use iota_types::{ @@ -51,9 +50,8 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned}; use typed_store::{ Map, database::Database, - metrics::SamplingInterval, rocks::{ - DBMap, DBOptions, MetricConf, ReadWriteOptions, default_db_options, list_tables, + DBBatch, DBMap, DBOptions, ReadWriteOptions, default_db_options, list_tables, read_size_from_env, }, rocksdb, @@ -61,8 +59,7 @@ use typed_store::{ use crate::authority::authority_store_types::{StoreObject, StoreObjectWrapper}; -const HISTORY_DIR_NAME: &str = "history"; -const META_CF_NAME: &str = "meta"; +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"; @@ -245,10 +242,13 @@ impl HistoricStoreMetrics { } } -/// Store of superseded object versions, bucketed by supersession epoch. +/// 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 exclusively from the single pruner task; reads may come from -/// any number of RPC threads concurrently. +/// 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 @@ -256,62 +256,57 @@ pub struct HistoricStore { cf_options: rocksdb::Options, meta: DBMap, buckets: RwLock>, - disable_wal: bool, metrics: Arc, } impl HistoricStore { - pub fn path(parent_path: &Path) -> PathBuf { - parent_path.join(HISTORY_DIR_NAME) - } - - /// Opens (or creates) the store under `/history`, - /// rediscovering all per-epoch column families present on disk. - /// - /// Relocation batches are written without the WAL when `disable_wal` is - /// set: relocation is idempotent and re-runnable from the pruner - /// watermark, and the pruner flushes the bucket before deleting the - /// source rows, so durability is preserved. - pub fn open( - parent_path: &Path, - disable_wal: bool, - metrics: Arc, - ) -> IotaResult { - let path = Self::path(parent_path); - let db_options = default_db_options().disable_write_throttling(); - let cf_options = Self::epoch_cf_options(&db_options); - let meta_options = db_options.clone().optimize_for_point_lookup(8); - - // Column families must be passed at open with their tuned options; - // any column family left for auto-discovery would silently get - // default options (and its own block cache). - let existing_cfs = list_tables(path.clone()).unwrap_or_default(); - let mut opt_cfs: Vec<(&str, rocksdb::Options)> = vec![(META_CF_NAME, meta_options.options)]; - for cf_name in &existing_cfs { - if cf_name != META_CF_NAME { - opt_cfs.push((cf_name, cf_options.clone())); + /// 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(), + }, + )); } } - - let db = typed_store::rocks::open_cf_opts( - &path, - Some(db_options.options), - MetricConf::new("history") - .with_sampling(SamplingInterval::new(Duration::from_secs(60), 0)), - &opt_cfs, - )?; - + 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 on disk are the ground truth for which buckets - // exist; `meta` may lag by one crash (bucket created, meta row not yet + // 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 &existing_cfs { + for cf_name in db.column_family_names() { let Some(epoch_str) = EPOCH_CF_PREFIXES .iter() .find_map(|prefix| cf_name.strip_prefix(prefix)) @@ -339,7 +334,6 @@ impl HistoricStore { cf_options, meta, buckets: RwLock::new(buckets), - disable_wal, metrics, }; store.update_retention_metrics(); @@ -409,16 +403,23 @@ impl HistoricStore { } } - /// Durably persists relocated rows and the tombstone-head expiry list - /// into the bucket for `supersession_epoch`, creating the bucket on first - /// use. Idempotent: rewriting the same keys with the same bytes is - /// harmless. + /// 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. /// - /// Durability of the write is only guaranteed after a subsequent - /// [`Self::flush_epoch`]; callers must flush before deleting the source - /// rows from the live table. - pub fn put_objects( + /// [`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], @@ -426,13 +427,11 @@ impl HistoricStore { if objects.is_empty() && tombstone_heads.is_empty() { return Ok(()); } - self.ensure_bucket(supersession_epoch)?; let buckets = self.buckets.read().expect("lock should not be poisoned"); let bucket = buckets .get(&supersession_epoch) - .expect("bucket was just created"); + .expect("prepare_bucket must be called before staging"); - let mut batch = bucket.objects.batch(); batch.insert_batch(&bucket.objects, objects.iter().map(|(k, v)| (k, v)))?; batch.insert_batch(&bucket.expiry, tombstone_heads.iter().map(|k| (k, ())))?; @@ -441,10 +440,6 @@ impl HistoricStore { info.expiry_count += tombstone_heads.len() as u64; batch.insert_batch(&self.meta, [(supersession_epoch, info)])?; - let mut write_options = rocksdb::WriteOptions::default(); - write_options.disable_wal(self.disable_wal); - batch.write_opt(&write_options)?; - self.metrics.relocated_objects.inc_by(objects.len() as u64); let relocated_bytes: u64 = objects .iter() @@ -454,27 +449,44 @@ impl HistoricStore { Ok(()) } - /// Durably persists one epoch-homogeneous batch of checkpoint-keyed - /// history into the bucket for `epoch`, creating the bucket on first use. - /// Idempotent: rewriting the same keys with the same bytes is harmless. + /// 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. /// - /// Durability of the write is only guaranteed after a subsequent - /// [`Self::flush_epoch`]; callers must flush before deleting the source - /// rows. - pub fn put_checkpoint_data( + /// [`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(()); } - self.ensure_bucket(epoch)?; let buckets = self.buckets.read().expect("lock should not be poisoned"); - let bucket = buckets.get(&epoch).expect("bucket was just created"); + let bucket = buckets + .get(&epoch) + .expect("prepare_bucket must be called before staging"); let num_transactions = data.transactions.len() as u64; - let mut batch = bucket.transactions.batch(); batch.insert_batch(&bucket.transactions, data.transactions)?; batch.insert_batch(&bucket.effects, data.effects)?; batch.insert_batch(&bucket.executed_effects, data.executed_effects)?; @@ -493,23 +505,24 @@ impl HistoricStore { } batch.insert_batch(&self.meta, [(epoch, info)])?; - let mut write_options = rocksdb::WriteOptions::default(); - write_options.disable_wal(self.disable_wal); - batch.write_opt(&write_options)?; - self.metrics.relocated_transactions.inc_by(num_transactions); Ok(()) } - /// Flushes the bucket's memtables to disk. This is the durability barrier - /// for WAL-less relocation writes: it must complete before the relocated - /// rows are deleted from the live table. - pub fn flush_epoch(&self, epoch: EpochId) -> IotaResult<()> { - let buckets = self.buckets.read().expect("lock should not be poisoned"); - let Some(bucket) = buckets.get(&epoch) else { + /// 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(()); - }; - bucket.flush_all()?; + } + self.prepare_bucket(epoch)?; + let mut batch = self.meta.batch(); + self.stage_checkpoint_data(&mut batch, epoch, data)?; + batch.write()?; Ok(()) } @@ -702,19 +715,6 @@ impl HistoricStore { Ok(self.meta.get(&epoch)?.is_some_and(|info| info.sealed)) } - /// Takes a RocksDB checkpoint of the whole history DB (all epoch column - /// families) at `path`. - /// - /// Callers snapshotting multiple stores must snapshot the *source* - /// stores (perpetual, checkpoints) before this one: relocation writes - /// history before deleting the source rows, so source-first ordering - /// can at worst capture a harmless duplicate, while history-first has a - /// window where a row relocated in between is in neither snapshot. - pub fn checkpoint_db(&self, path: &Path) -> IotaResult<()> { - // Checkpointing any map snapshots the whole database. - self.meta.checkpoint_db(path).map_err(Into::into) - } - fn ensure_bucket(&self, epoch: EpochId) -> IotaResult<()> { { let buckets = self.buckets.read().expect("lock should not be poisoned"); @@ -751,7 +751,19 @@ mod tests { use crate::authority::authority_store_types::get_store_object; fn open_store(path: &Path) -> HistoricStore { - HistoricStore::open(path, true, HistoricStoreMetrics::new_for_test()).unwrap() + 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) { @@ -791,7 +803,6 @@ mod tests { &[ObjectKey(key.0, SequenceNumber::from_u64(3))], ) .unwrap(); - store.flush_epoch(7).unwrap(); store.seal_epoch(7).unwrap(); assert!(store.is_sealed(7).unwrap()); } @@ -950,12 +961,19 @@ mod tests { .unwrap(); store.seal_epoch(3).unwrap(); + // A snapshot of the shared database covers every epoch column family. let copy_dir = iota_common::tempdir(); - store - .checkpoint_db(©_dir.path().join(HISTORY_DIR_NAME)) - .unwrap(); - - let copy = open_store(copy_dir.path()); + 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()); 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..75d943d3db1e 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,73 @@ 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: Option<&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 + .map(|store| store.get_object(&key)) + .transpose()? + .flatten() + .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: Option<&HistoricStore>, ) -> IotaResult { let event_tx_digests = checkpoint_tx_data .effects @@ -53,10 +104,33 @@ 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::>>()?, + None => iota_types::storage::get_transaction_output_objects(object_store, fx) + .map_err(|e| IotaError::Unknown(e.to_string()))?, + }; 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..f52074dfc2d2 100644 --- a/crates/iota-core/src/checkpoints/checkpoint_executor/mod.rs +++ b/crates/iota-core/src/checkpoints/checkpoint_executor/mod.rs @@ -683,6 +683,7 @@ impl CheckpointExecutor { tx_data, self.state.get_object_store(), &*self.transaction_cache_reader, + self.state.historic_store.as_deref(), ) .expect("failed to load checkpoint data"); @@ -1027,6 +1028,7 @@ impl CheckpointExecutor { &tx_data, self.state.get_object_store(), self.transaction_cache_reader.as_ref(), + self.state.historic_store.as_deref(), ) .expect("Failed to load full CheckpointData") }; 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/transaction_outputs.rs b/crates/iota-core/src/transaction_outputs.rs index 275e553ba1d1..dc0a6829701e 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 { @@ -38,6 +46,7 @@ impl TransactionOutputs { transaction: VerifiedTransaction, effects: TransactionEffects, inner_temporary_store: InnerTemporaryStore, + capture_superseded: bool, ) -> TransactionOutputs { let InnerTemporaryStore { input_objects, @@ -128,6 +137,24 @@ impl TransactionOutputs { let wrapped = effects.wrapped().into_iter().map(ObjectKey::from).collect(); + // The pre-images of superseded versions are exactly the mutated, + // deleted, wrapped and received inputs — all present in + // `input_objects` at their input version. Anything absent (or at an + // unexpected version) is skipped; the pruner relocates it later. + let superseded = if capture_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() + } else { + Vec::new() + }; + TransactionOutputs { transaction: Arc::new(transaction), effects, @@ -138,6 +165,7 @@ impl TransactionOutputs { live_object_markers_to_delete, new_live_object_markers_to_init, written, + superseded, } } } diff --git a/crates/iota-node/src/lib.rs b/crates/iota-node/src/lib.rs index e33a2a1de489..332670d3e8b7 100644 --- a/crates/iota-node/src/lib.rs +++ b/crates/iota-node/src/lib.rs @@ -445,22 +445,11 @@ impl IotaNode { historic_store_config = None; } } - let historic_store = historic_store_config - .map(|historic_config| { - HistoricStore::open( - &config.db_path().join("store"), - historic_config.disable_wal, - HistoricStoreMetrics::new(&prometheus_registry), - ) - .map(Arc::new) - }) - .transpose()?; - let mut pruner_db = None; if config .authority_store_pruning_config .enable_compaction_filter - && historic_store.is_none() + && historic_store_config.is_none() { pruner_db = Some(Arc::new(AuthorityPrunerTables::open( &config.db_path().join("store"), @@ -472,14 +461,34 @@ impl IotaNode { // 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 = if historic_store_config.is_some() { + HistoricStore::extra_column_family_options(&AuthorityPerpetualTables::path( + &config.db_path().join("store"), + )) + } else { + Vec::new() + }; 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 = historic_store_config + .map(|_| { + HistoricStore::new_shared( + perpetual_tables.database(), + HistoricStoreMetrics::new(&prometheus_registry), + ) + .map(Arc::new) + }) + .transpose()?; let is_genesis = perpetual_tables .database_is_empty() .expect("Database read should not fail at init."); @@ -502,6 +511,7 @@ impl IotaNode { &config, &prometheus_registry, migration_tx_data.as_ref(), + historic_store.clone(), ) .await?; diff --git a/crates/iota-tool/src/lib.rs b/crates/iota-tool/src/lib.rs index c9793d471fe3..6d1a11e00d23 100644 --- a/crates/iota-tool/src/lib.rs +++ b/crates/iota-tool/src/lib.rs @@ -642,7 +642,7 @@ 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 store = AuthorityStore::open_no_genesis(perpetual_db, false, &Registry::default(), None)?; let cache_traits = build_execution_cache_from_env(&Registry::default(), &store); let state_sync_store = RocksDbStore::new( cache_traits, @@ -1103,7 +1103,7 @@ pub async fn download_formal_snapshot( .await?; let authority_store = - AuthorityStore::open_no_genesis(perpetual_db.clone(), false, &Registry::default())?; + AuthorityStore::open_no_genesis(perpetual_db.clone(), false, &Registry::default(), None)?; 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 521f89fda717..44c8b75b602c 100644 --- a/crates/typed-store/src/database.rs +++ b/crates/typed-store/src/database.rs @@ -227,6 +227,19 @@ 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> { From f0121c00f1745fee04d47f2e231009aaa9f69d45 Mon Sep 17 00:00:00 2001 From: muXxer Date: Sun, 12 Jul 2026 16:24:17 +0200 Subject: [PATCH 05/12] feat(core): pre-create next historic epoch table and cache metadata --- .../src/authority/authority_store.rs | 13 ++++- .../src/authority/authority_store_pruner.rs | 3 +- .../iota-core/src/authority/historic_store.rs | 47 +++++++++++++------ 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/crates/iota-core/src/authority/authority_store.rs b/crates/iota-core/src/authority/authority_store.rs index c664ae66e653..8b577611060f 100644 --- a/crates/iota-core/src/authority/authority_store.rs +++ b/crates/iota-core/src/authority/authority_store.rs @@ -851,9 +851,20 @@ impl AuthorityStore { // 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 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). if let Some(historic_store) = &self.historic_store { historic_store.prepare_bucket(epoch_id)?; + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let historic_store = 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(); diff --git a/crates/iota-core/src/authority/authority_store_pruner.rs b/crates/iota-core/src/authority/authority_store_pruner.rs index 5960a44c6e9f..00c151532092 100644 --- a/crates/iota-core/src/authority/authority_store_pruner.rs +++ b/crates/iota-core/src/authority/authority_store_pruner.rs @@ -1794,7 +1794,8 @@ mod tests { 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()); - assert_eq!(historic.list_epochs(), vec![3]); + // The commit pre-creates the next epoch's bucket alongside its own. + assert_eq!(historic.list_epochs(), vec![3, 4]); assert!( historic .get_object(&key_v1) diff --git a/crates/iota-core/src/authority/historic_store.rs b/crates/iota-core/src/authority/historic_store.rs index 5a4127202d38..83d78d4be620 100644 --- a/crates/iota-core/src/authority/historic_store.rs +++ b/crates/iota-core/src/authority/historic_store.rs @@ -111,6 +111,10 @@ pub struct EpochBucketInfo { } 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 @@ -326,7 +330,7 @@ impl HistoricStore { .map_err(|e| IotaError::Storage(e.to_string()))?; } } - buckets.insert(epoch, Self::reopen_bucket(&db, epoch)?); + buckets.insert(epoch, Self::reopen_bucket(&db, &meta, epoch)?); } let store = Self { @@ -370,7 +374,11 @@ impl HistoricStore { EPOCH_CF_PREFIXES.map(|prefix| format!("{prefix}{epoch}")) } - fn reopen_bucket(db: &Arc, epoch: EpochId) -> IotaResult { + 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. @@ -383,6 +391,7 @@ impl HistoricStore { )?) } 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}"))?, @@ -435,9 +444,12 @@ impl HistoricStore { batch.insert_batch(&bucket.objects, objects.iter().map(|(k, v)| (k, v)))?; batch.insert_batch(&bucket.expiry, tombstone_heads.iter().map(|k| (k, ())))?; - let mut info = self.meta.get(&supersession_epoch)?.unwrap_or_default(); - info.object_count += objects.len() as u64; - info.expiry_count += tombstone_heads.len() as u64; + 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); @@ -498,11 +510,14 @@ impl HistoricStore { )?; batch.insert_batch(&bucket.checkpoints, data.checkpoints)?; - let mut info = self.meta.get(&epoch)?.unwrap_or_default(); - 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)); - } + 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); @@ -546,9 +561,13 @@ impl HistoricStore { .compact_range_raw(&cf_name, vec![], full_range_end.clone())?; } } - let mut info = self.meta.get(&epoch)?.unwrap_or_default(); - if !info.sealed { - info.sealed = true; + 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(()) @@ -735,7 +754,7 @@ impl HistoricStore { .map_err(|e| IotaError::Storage(e.to_string()))?; } } - buckets.insert(epoch, Self::reopen_bucket(&self.db, epoch)?); + buckets.insert(epoch, Self::reopen_bucket(&self.db, &self.meta, epoch)?); drop(buckets); self.update_retention_metrics(); Ok(()) From 2b9a19e287e33cbd86b3785b8c3fc2986d673ca3 Mon Sep 17 00:00:00 2001 From: muXxer Date: Sun, 12 Jul 2026 17:34:11 +0200 Subject: [PATCH 06/12] fix(core): never block checkpoint execution on pruning backlog Execution-driven pruning (#12186) blocked the checkpoint executor while pruning had fallen more than an hour of chain time behind. On upgrade, many real nodes start with a larger backlog than that (downtime, retention changes, catch-up sync, an old ticker-based pruner that fell behind), and since the pruner only publishes progress after a full drain, such nodes would stall execution for the whole first drain instead of being throttled. Pruning is now best-effort background work that never gates execution; a backlog grows the database temporarily and is surfaced instead of prevented: - `pruning_chain_time_lag_ms` gauge: chain time between the executed watermark and the target of the pruner's last completed drain, plus a rate-limited warning above the old one-hour threshold. - `last_pruned_checkpoint_timestamp_ms` / `last_pruned_effects_checkpoint_timestamp_ms` gauges, published per pruned batch so dashboards show progress during long drains. The nudge channel and the drain loop are unchanged. --- crates/iota-core/src/authority.rs | 4 +- .../src/authority/authority_store_pruner.rs | 298 +++++++++++------- .../checkpoints/checkpoint_executor/mod.rs | 14 - 3 files changed, 179 insertions(+), 137 deletions(-) diff --git a/crates/iota-core/src/authority.rs b/crates/iota-core/src/authority.rs index 26787fd1065b..5f157ff31bd2 100644 --- a/crates/iota-core/src/authority.rs +++ b/crates/iota-core/src/authority.rs @@ -874,7 +874,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>, @@ -4366,7 +4366,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 } diff --git a/crates/iota-core/src/authority/authority_store_pruner.rs b/crates/iota-core/src/authority/authority_store_pruner.rs index 00c151532092..58108b255b82 100644 --- a/crates/iota-core/src/authority/authority_store_pruner.rs +++ b/crates/iota-core/src/authority/authority_store_pruner.rs @@ -81,20 +81,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 @@ -107,27 +109,19 @@ const PRUNING_DEBOUNCE_MIN_LAG: u64 = 100; /// 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 { @@ -136,26 +130,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 @@ -168,6 +142,9 @@ pub struct AuthorityStorePruningMetrics { pub last_pruned_indexes_transaction: IntGauge, pub num_epochs_to_retain_for_objects: 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, } impl AuthorityStorePruningMetrics { @@ -218,6 +195,25 @@ 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(), }; Arc::new(this) } @@ -745,6 +741,7 @@ impl AuthorityStorePruner { let _scope = monitored_scope("PruneForEligibleEpochs"); let mut checkpoint_number = starting_checkpoint_number; + let mut last_pruned_timestamp_ms = 0; let current_epoch = checkpoint_store .get_highest_executed_checkpoint()? .map(|c| c.epoch()) @@ -805,6 +802,15 @@ impl AuthorityStorePruner { 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 { @@ -840,6 +846,7 @@ impl AuthorityStorePruner { } 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)? @@ -880,6 +887,16 @@ impl AuthorityStorePruner { ) .await?; + // Published per batch so dashboards show progress during long + // drains, not only at drain completion. + 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); + // Report pruning time for this batch so the progress logger // shows time alongside the checkpoint deltas it reads from the // DB (which are already updated at this point). @@ -916,6 +933,12 @@ impl AuthorityStorePruner { ) .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); + // Report pruning time for this batch so the progress logger // shows time alongside the checkpoint deltas it reads from the // DB (which are already updated at this point). @@ -1099,7 +1122,6 @@ impl AuthorityStorePruner { 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!( @@ -1155,25 +1177,27 @@ impl AuthorityStorePruner { 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; + // Lag tracking only makes sense when something is actually being + // pruned. + let track_lag = 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; 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`. + // The executed position this pass prunes up to. let highest_executed = checkpoint_store .get_highest_executed_checkpoint() .ok() @@ -1183,6 +1207,22 @@ impl AuthorityStorePruner { .map(|checkpoint| checkpoint.timestamp_ms) .unwrap_or(u64::MAX); + if track_lag { + 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 @@ -1263,8 +1303,9 @@ impl AuthorityStorePruner { } } - if leash_enabled { - frontier_tx.send_replace(caught_up_to); + if track_lag { + last_drain_target_ms = caught_up_to; + metrics.pruning_chain_time_lag_ms.set(0); } tokio::select! { @@ -1337,13 +1378,10 @@ impl AuthorityStorePruner { } } - // 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, @@ -1358,10 +1396,8 @@ impl AuthorityStorePruner { archive_readers, progress_tracker, executed_rx, - frontier_ms.clone(), ), executed, - frontier_ms, } } @@ -1506,7 +1542,7 @@ mod tests { rocks::{DBMap, MetricConf, ReadWriteOptions, default_db_options}, }; - use super::{AuthorityStorePruner, HistoricRelocation, PRUNING_LEASH_SLACK_MS, PruningMode}; + use super::{AuthorityStorePruner, HistoricRelocation, PruningMode}; use crate::{ authority::{ authority_store_pruner::AuthorityStorePruningMetrics, @@ -2392,6 +2428,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)); @@ -2414,8 +2473,6 @@ mod tests { .update_highest_executed_checkpoint(checkpoints.last().unwrap()) .unwrap(); - let registry = Registry::default(); - let metrics = AuthorityStorePruningMetrics::new(®istry); AuthorityStorePruner::prune_for_eligible_epochs( &perpetual_db, &checkpoint_store, @@ -2423,7 +2480,7 @@ mod tests { None, None, true, - PruningMode::Checkpoints, + mode, num_epochs_to_retain, 0, max_eligible_checkpoint, @@ -2434,9 +2491,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 @@ -2485,59 +2545,55 @@ 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, - } - } - - // 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). - #[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; - } - - // The leash blocks while the pruner is more than the slack behind, and - // releases once the frontier advances. + // 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_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; - - let waiter = pruner.clone(); - let handle = tokio::spawn(async move { waiter.await_leash(executed_ts).await }); + 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 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" + 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 ); - - // Once the pruner catches up, the leash releases. - pruner.frontier_ms.send_replace(executed_ts); - handle - .await - .expect("leash should release after frontier advances"); + assert_eq!(metrics.last_pruned_checkpoint_timestamp_ms.get(), 0); } // 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/checkpoints/checkpoint_executor/mod.rs b/crates/iota-core/src/checkpoints/checkpoint_executor/mod.rs index f52074dfc2d2..9f41246295e0 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 From 86626897b7647938c43b5909894b1078a151bbb9 Mon Sep 17 00:00:00 2001 From: muXxer Date: Sun, 12 Jul 2026 18:34:34 +0200 Subject: [PATCH 07/12] feat(core): make the historic store always-on and remove delete-mode pruning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every node now relocates superseded object versions and pruned checkpoint data into historic epoch buckets — there is no enable flag, no fullnode-only gating, and no alternative pruning implementation: - Config collapses to a single `historic-epochs-to-retain` knob (default 2, roughly today's default disk profile; RPC operators raise it). `num-epochs-to-retain`, `enable-compaction-filter` and the `historic-store` section are gone; unknown keys in existing config files are ignored. - The compaction-filter pruning mode (`ObjectsCompactionFilter`, `AuthorityPrunerTables`, the `pruner` database) and the range-delete branch are deleted; relocation is the only implementation. - The objects walker drains to the executed watermark with no retention window: relocation is not deletion, so nothing needs protecting. For data written by this version it finds nothing (commit-time relocation already moved it) — its work is pre-existing databases and capture misses. - `HistoricStore` is constructed unconditionally (node, tools, test builder), `Option>` becomes `Arc` throughout, and superseded pre-images are always captured in transaction outputs. The whole existing test suite now runs with relocation active. - DB-checkpoint upload compacts only (nothing left to prune in a snapshot of a continuously pruned source). --- .../data/fullnode-template-with-path.yaml | 4 +- .../iota-config/data/fullnode-template.yaml | 4 +- crates/iota-config/src/node.rs | 56 +- crates/iota-core/src/authority.rs | 14 +- .../src/authority/authority_store.rs | 97 ++- .../src/authority/authority_store_pruner.rs | 707 ++++++------------ .../src/authority/authority_store_tables.rs | 45 +- .../src/authority/test_authority_builder.rs | 30 +- .../data_ingestion_handler.rs | 8 +- .../checkpoints/checkpoint_executor/mod.rs | 4 +- crates/iota-core/src/db_checkpoint_handler.rs | 78 +- crates/iota-core/src/storage.rs | 61 +- crates/iota-core/src/transaction_outputs.rs | 23 +- crates/iota-node/src/lib.rs | 70 +- crates/iota-proxy/README.md | 1 - .../src/node_config_builder.rs | 1 - ...ests__network_config_snapshot_matches.snap | 14 +- crates/iota-tool/src/db_tool/db_dump.rs | 25 +- crates/iota-tool/src/lib.rs | 31 +- docs/content/operator/common/pruning.mdx | 86 +-- setups/fullnode/fullnode-devnet.yaml | 5 +- setups/fullnode/fullnode-mainnet.yaml | 5 +- setups/fullnode/fullnode-testnet.yaml | 5 +- setups/validator/ssfn-mainnet.yaml | 1 - setups/validator/ssfn-testnet.yaml | 1 - setups/validator/validator-mainnet.yaml | 1 - setups/validator/validator-testnet.yaml | 1 - 27 files changed, 428 insertions(+), 950 deletions(-) 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 ca72820fe5d6..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,45 +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, - /// Enables the live/historic object split: instead of deleting superseded - /// object versions after `num_epochs_to_retain` epochs, the pruner - /// relocates them into per-epoch historic stores where they remain - /// readable through exact-version RPC lookups and are dropped wholesale - /// once out of retention. Fullnode-only; incompatible with - /// `enable_compaction_filter`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub historic_store: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub struct HistoricStoreConfig { - /// Number of epochs of superseded object versions to retain, bucketed by - /// the epoch in which they were superseded. Whole epoch buckets are - /// dropped once they fall out of this window. + /// 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 num_epochs_to_retain: u64, + pub historic_epochs_to_retain: u64, } fn default_historic_epochs_to_retain() -> u64 { - 100 -} - -impl Default for HistoricStoreConfig { - fn default() -> Self { - Self { - num_epochs_to_retain: default_historic_epochs_to_retain(), - } - } + 2 } fn default_num_latest_epoch_dbs_to_retain() -> usize { @@ -1102,21 +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_store: 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 5f157ff31bd2..deb6d411cc27 100644 --- a/crates/iota-core/src/authority.rs +++ b/crates/iota-core/src/authority.rs @@ -157,7 +157,6 @@ 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, }, @@ -858,7 +857,7 @@ pub struct AuthorityState { /// 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: Option>, + pub historic_store: Arc, pub subscription_handler: Arc, pub checkpoint_store: Arc, @@ -1731,7 +1730,6 @@ impl AuthorityState { transaction.clone().into_unsigned(), effects.clone(), inner_temporary_store, - self.historic_store.is_some(), ); self.get_cache_writer() .try_write_transaction_outputs(epoch_store.epoch(), transaction_outputs.into())?; @@ -3266,8 +3264,7 @@ impl AuthorityState { archive_readers: ArchiveReaderBalancer, validator_tx_finalizer: Option>>, chain_identifier: ChainIdentifier, - pruner_db: Option>, - historic_store: Option>, + historic_store: Arc, checkpoint_progress_tracker: Option>, policy_config: Option, firewall_config: Option, @@ -3300,11 +3297,9 @@ 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(), ); @@ -3437,9 +3432,8 @@ impl AuthorityState { &self.database_for_testing().perpetual_tables, &self.checkpoint_store, self.grpc_indexes_store.as_deref(), - None, - self.historic_store.as_ref(), - config.authority_store_pruning_config, + &self.historic_store, + &config.authority_store_pruning_config, metrics, archive_readers, EPOCH_DURATION_MS_FOR_TESTING, diff --git a/crates/iota-core/src/authority/authority_store.rs b/crates/iota-core/src/authority/authority_store.rs index 8b577611060f..4c7cdfcf6dd6 100644 --- a/crates/iota-core/src/authority/authority_store.rs +++ b/crates/iota-core/src/authority/authority_store.rs @@ -44,13 +44,11 @@ use super::{ 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, + historic_store::{HistoricStore, HistoricStoreMetrics}, }, global_state_hasher::GlobalStateHashStore, grpc_indexes::GrpcIndexesStore, @@ -126,10 +124,10 @@ pub struct AuthorityStore { pub(crate) perpetual_tables: Arc, - /// When set, 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: Option>, + /// 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, @@ -152,7 +150,7 @@ impl AuthorityStore { config: &NodeConfig, registry: &Registry, migration_tx_data: Option<&MigrationTxData>, - historic_store: Option>, + historic_store: Arc, ) -> IotaResult> { let enable_epoch_iota_conservation_check = config .expensive_safety_check_config @@ -239,13 +237,20 @@ 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); + 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, - None, + historic_store, ) .await } @@ -256,7 +261,7 @@ impl AuthorityStore { enable_epoch_iota_conservation_check: bool, registry: &Registry, migration_tx_data: Option<&MigrationTxData>, - historic_store: Option>, + historic_store: Arc, ) -> IotaResult> { let store = Arc::new(Self { mutex_table: MutexTable::new(NUM_SHARDS), @@ -385,7 +390,7 @@ impl AuthorityStore { perpetual_tables: Arc, enable_epoch_iota_conservation_check: bool, registry: &Registry, - historic_store: Option>, + historic_store: Arc, ) -> IotaResult> { let store = Arc::new(Self { mutex_table: MutexTable::new(NUM_SHARDS), @@ -855,16 +860,14 @@ impl AuthorityStore { // 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). - if let Some(historic_store) = &self.historic_store { - historic_store.prepare_bucket(epoch_id)?; - if let Ok(handle) = tokio::runtime::Handle::try_current() { - let historic_store = 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:?}"); - } - }); - } + 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(); @@ -950,26 +953,25 @@ impl AuthorityStore { write_batch.insert_batch(&self.perpetual_tables.objects, new_objects)?; - // With the historic store enabled, 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. - if let Some(historic_store) = &self.historic_store { - 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(); - historic_store.stage_objects(write_batch, epoch_id, &relocated, &tombstone_heads)?; - write_batch.delete_batch( - &self.perpetual_tables.objects, - relocated.iter().map(|(key, _)| *key), - )?; - } + // 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() { @@ -1715,19 +1717,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, - 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 58108b255b82..15d21a10924a 100644 --- a/crates/iota-core/src/authority/authority_store_pruner.rs +++ b/crates/iota-core/src/authority/authority_store_pruner.rs @@ -3,20 +3,19 @@ // 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_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}, @@ -39,18 +38,11 @@ use tokio::{ time::Instant, }; use tracing::{debug, error, info, warn}; -use typed_store::{ - Map, TypedStoreError, - rocks::DBBatch, - 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; use crate::{ - authority::{ - authority_store_types::{StoreObject, StoreObjectWrapper}, - historic_store::HistoricStore, - }, + authority::historic_store::HistoricStore, checkpoint_progress_tracker::CheckpointProgressTracker, checkpoints::{CheckpointStore, CheckpointWatermark}, grpc_indexes::GrpcIndexesStore, @@ -140,7 +132,7 @@ 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, @@ -183,9 +175,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(), @@ -241,20 +233,20 @@ struct HistoricRelocation<'a> { } 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. async fn prune_objects( transaction_effects: Vec, perpetual_db: &Arc, - pruner_db: Option<&Arc>, - relocation: Option>, + relocation: &HistoricRelocation<'_>, checkpoint_number: CheckpointSequenceNumber, metrics: Arc, ) -> 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 { @@ -275,81 +267,15 @@ impl AuthorityStorePruner { .num_pruned_tombstones .inc_by(object_tombstones_to_prune.len() as u64); - if let Some(relocation) = relocation { - debug_assert!( - pruner_db.is_none(), - "the compaction filter and the historic store are mutually exclusive" - ); - 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()?; - return Ok(()); - } - - 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)); - } - - 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)?; - } - } - } - - // 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); - } - } - - wb.delete_batch(&perpetual_db.objects, object_keys_to_delete)?; - } - + 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); - - if let Some(batch) = pruner_db_wb { - batch.write()?; - } wb.write()?; Ok(()) } @@ -498,20 +424,19 @@ impl AuthorityStorePruner { /// prune. This function removes outdated data, updates pruning metrics, /// and maintains database consistency by updating watermarks. /// - /// With `relocation` set, 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 still - /// 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). + /// 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: Option>, + relocation: &HistoricRelocation<'_>, checkpoint_number: CheckpointSequenceNumber, checkpoints_to_prune: Vec, checkpoint_content_to_prune: Vec, @@ -526,18 +451,16 @@ impl AuthorityStorePruner { .flat_map(|content| content.iter().map(|tx| tx.transaction)) .collect(); - if let Some(relocation) = &relocation { - Self::relocate_checkpoint_data( - &mut perpetual_batch, - perpetual_db, - checkpoint_db, - relocation, - &transactions, - &checkpoints_to_prune, - &checkpoint_content_to_prune, - effects_to_prune, - )?; - } + 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())?; @@ -599,31 +522,29 @@ 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>, - historic_store: Option<&Arc>, - config: AuthorityStorePruningConfig, + historic_store: &Arc, + config: &AuthorityStorePruningConfig, metrics: Arc, - epoch_duration_ms: u64, progress_tracker: Option<&Arc>, ) -> 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()? @@ -640,11 +561,10 @@ impl AuthorityStorePruner { perpetual_db, checkpoint_store, grpc_indexes_store, - pruner_db, historic_store, seals_buckets, PruningMode::Objects, - config.num_epochs_to_retain, + 0, pruned_checkpoint_number, max_eligible_checkpoint_number, cutoff_timestamp_ms, @@ -666,9 +586,8 @@ impl AuthorityStorePruner { perpetual_db: &Arc, checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, - pruner_db: Option<&Arc>, - historic_store: Option<&Arc>, - config: AuthorityStorePruningConfig, + historic_store: &Arc, + config: &AuthorityStorePruningConfig, metrics: Arc, archive_readers: ArchiveReaderBalancer, epoch_duration_ms: u64, @@ -686,15 +605,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"))?; @@ -705,7 +624,6 @@ impl AuthorityStorePruner { perpetual_db, checkpoint_store, grpc_indexes_store, - pruner_db, historic_store, // The checkpoint pruner always seals: its eligibility is capped // at the objects watermark, so it is the lagging pruning mode. @@ -727,8 +645,7 @@ impl AuthorityStorePruner { perpetual_db: &Arc, checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, - pruner_db: Option<&Arc>, - historic_store: Option<&Arc>, + historic_store: &Arc, seals_buckets: bool, mode: PruningMode, num_epochs_to_retain: u64, @@ -777,72 +694,64 @@ impl AuthorityStorePruner { break; } - // With relocation enabled 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. - if let Some(store) = historic_store { - match batch_epoch { - Some(epoch) if epoch != checkpoint.epoch() => { - if !checkpoints_to_prune.is_empty() { - Self::prune_batch( - perpetual_db, - checkpoint_store, - grpc_indexes_store, - pruner_db, - Some(HistoricRelocation { - store, - supersession_epoch: epoch, - }), - 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?; + // 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() { + Self::prune_batch( + perpetual_db, + checkpoint_store, + grpc_indexes_store, + &HistoricRelocation { + store: historic_store, + supersession_epoch: epoch, + }, + 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 => { - &metrics.last_pruned_checkpoint_timestamp_ms - } + PruningMode::Objects => tracker.add_object_pruning_time(elapsed), PruningMode::Checkpoints => { - &metrics.last_pruned_effects_checkpoint_timestamp_ms + tracker.add_checkpoint_pruning_time(elapsed) } } - .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 { - store.seal_epoch(epoch)?; + pruning_start = Instant::now(); } } - 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 store.list_epochs() { - if epoch >= checkpoint.epoch() { - break; - } - if !store.is_sealed(epoch)? { - store.seal_epoch(epoch)?; - } + 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(); @@ -872,12 +781,11 @@ impl AuthorityStorePruner { perpetual_db, checkpoint_store, grpc_indexes_store, - pruner_db, - historic_store.map(|store| HistoricRelocation { - store, + &HistoricRelocation { + store: historic_store, supersession_epoch: batch_epoch .expect("batch epoch is set before batching"), - }), + }, mode, checkpoint_number, std::mem::take(&mut checkpoints_to_prune), @@ -919,11 +827,10 @@ impl AuthorityStorePruner { perpetual_db, checkpoint_store, grpc_indexes_store, - pruner_db, - historic_store.map(|store| HistoricRelocation { - store, + &HistoricRelocation { + store: historic_store, supersession_epoch: batch_epoch.expect("batch epoch is set before batching"), - }), + }, mode, checkpoint_number, checkpoints_to_prune, @@ -959,8 +866,7 @@ impl AuthorityStorePruner { perpetual_db: &Arc, checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, - pruner_db: Option<&Arc>, - relocation: Option>, + relocation: &HistoricRelocation<'_>, mode: PruningMode, checkpoint_number: CheckpointSequenceNumber, checkpoints_to_prune: Vec, @@ -973,7 +879,6 @@ impl AuthorityStorePruner { Self::prune_objects( effects_to_prune, perpetual_db, - pruner_db, relocation, checkpoint_number, metrics, @@ -1116,8 +1021,7 @@ impl AuthorityStorePruner { checkpoint_store: Arc, grpc_indexes_store: Option>, jsonrpc_index: Option>, - pruner_db: Option>, - historic_store: Option>, + historic_store: Arc, metrics: Arc, archive_readers: ArchiveReaderBalancer, progress_tracker: Option>, @@ -1125,15 +1029,11 @@ impl AuthorityStorePruner { ) -> 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_store - .as_ref() - .map(|c| c.num_epochs_to_retain) - .unwrap_or(u64::MAX); + 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. @@ -1163,23 +1063,19 @@ 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(); - // Lag tracking only makes sense when something is actually being - // pruned. - let track_lag = prune_objects || prune_checkpoints; // Execution-driven pruning: on every nudge from the checkpoint executor, // drain each enabled pruner fully to its chain-time cutoff. Draining @@ -1207,20 +1103,18 @@ impl AuthorityStorePruner { .map(|checkpoint| checkpoint.timestamp_ms) .unwrap_or(u64::MAX); - if track_lag { - 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()); - } + 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 @@ -1239,49 +1133,41 @@ 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(), - historic_store.as_ref(), - config.clone(), - metrics.clone(), - epoch_duration_ms, - progress_tracker.as_ref(), - ) - .await - { - error!("Failed to prune objects: {:?}", err); - } + if let Err(err) = 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 + { + error!("Failed to prune objects: {:?}", err); } // 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. Not part of - // the leash — dropping old buckets never blocks execution. - if let Some(store) = &historic_store { - 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, - store, - current_epoch, - historic_epochs_to_retain, - ) { - error!("Failed to drop expired historic epochs: {:?}", err); - } + // 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(), - historic_store.as_ref(), - config.clone(), + &historic_store, + &config, metrics.clone(), archive_readers.clone(), epoch_duration_ms, @@ -1303,10 +1189,8 @@ impl AuthorityStorePruner { } } - if track_lag { - last_drain_target_ms = caught_up_to; - metrics.pruning_chain_time_lag_ms.set(0); - } + last_drain_target_ms = caught_up_to; + metrics.pruning_chain_time_lag_ms.set(0); tokio::select! { _ = &mut recv => break, @@ -1334,48 +1218,15 @@ 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>, - mut historic_store: 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)"); - } - } - - assert!( - historic_store.is_none() || pruner_db.is_none(), - "the compaction filter pruner and the historic object store are mutually exclusive" - ); - if is_validator && historic_store.is_some() { - warn!("The historic object store is fullnode-only; disabling it on this validator."); - historic_store = None; - } - if historic_store.is_some() && pruning_config.num_epochs_to_retain == u64::MAX { - warn!( - "The historic object store is enabled but the objects pruner is disabled \ - (num_epochs_to_retain = u64::MAX); no object versions will ever be relocated." - ); - } - if historic_store.is_some() { - if let Err(err) = Self::fast_forward_objects_watermark(&perpetual_db, &checkpoint_store) - { - error!("Failed to fast-forward the objects pruning watermark: {err:?}"); - } + if let Err(err) = Self::fast_forward_objects_watermark(&perpetual_db, &checkpoint_store) { + error!("Failed to fast-forward the objects pruning watermark: {err:?}"); } // Coordination channel between the checkpoint executor and the pruner @@ -1390,7 +1241,6 @@ impl AuthorityStorePruner { checkpoint_store, grpc_indexes_store, jsonrpc_index, - pruner_db, historic_store, AuthorityStorePruningMetrics::new(registry), archive_readers, @@ -1446,76 +1296,6 @@ impl AuthorityStorePruner { } } -#[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(), - } - } - } - Ok(Decision::Keep) - } -} - -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(), - }) - } -} - #[cfg(test)] mod tests { use std::{collections::HashSet, path::Path, sync::Arc, time::Duration}; @@ -1675,9 +1455,19 @@ mod tests { ObjectDigest::MIN, )); } - AuthorityStorePruner::prune_objects(vec![effects], &db, None, None, 0, metrics) - .await - .unwrap(); + let historic = open_historic(&db); + AuthorityStorePruner::prune_objects( + vec![effects], + &db, + &HistoricRelocation { + store: &historic, + supersession_epoch: 0, + }, + 0, + metrics, + ) + .await + .unwrap(); to_keep }; tokio::time::sleep(Duration::from_secs(3)).await; @@ -1746,11 +1536,10 @@ mod tests { AuthorityStorePruner::prune_objects( vec![effects], db, - None, - Some(HistoricRelocation { + &HistoricRelocation { store: historic, supersession_epoch, - }), + }, checkpoint_number, AuthorityStorePruningMetrics::new_for_test(), ) @@ -1783,7 +1572,7 @@ mod tests { perpetual_db.clone(), false, &Registry::default(), - Some(historic.clone()), + historic.clone(), ) .unwrap(); @@ -2133,10 +1922,10 @@ mod tests { &perpetual_db, &checkpoint_db, None, - Some(HistoricRelocation { + &HistoricRelocation { store: &historic, supersession_epoch: committee.epoch, - }), + }, 9, vec![ckpt_digest], vec![contents.clone()], @@ -2270,20 +2059,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> { @@ -2306,27 +2081,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()); @@ -2339,11 +2112,14 @@ mod tests { } let registry = Registry::default(); let metrics = AuthorityStorePruningMetrics::new(®istry); + let historic = open_historic(&perpetual_db); let total_pruned = AuthorityStorePruner::prune_objects( vec![effects], &perpetual_db, - None, - None, + &HistoricRelocation { + store: &historic, + supersession_epoch: 0, + }, 0, metrics, ) @@ -2351,7 +2127,7 @@ mod tests { 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 = {:?}", @@ -2361,65 +2137,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. @@ -2473,12 +2190,12 @@ mod tests { .update_highest_executed_checkpoint(checkpoints.last().unwrap()) .unwrap(); + let historic = open_historic(&perpetual_db); AuthorityStorePruner::prune_for_eligible_epochs( &perpetual_db, &checkpoint_store, None, - None, - None, + &historic, true, mode, num_epochs_to_retain, diff --git a/crates/iota-core/src/authority/authority_store_tables.rs b/crates/iota-core/src/authority/authority_store_tables.rs index 72c31af54c5a..e28d13791cdc 100644 --- a/crates/iota-core/src/authority/authority_store_tables.rs +++ b/crates/iota-core/src/authority/authority_store_tables.rs @@ -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,6 @@ 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. @@ -160,27 +156,6 @@ 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, - ) - } -} - /// The total IOTA supply used during conservation checks. #[derive(Debug, Serialize, Deserialize)] pub(crate) struct TotalIotaSupplyCheck { @@ -205,7 +180,7 @@ impl AuthorityPerpetualTables { 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(), @@ -699,23 +674,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/test_authority_builder.rs b/crates/iota-core/src/authority/test_authority_builder.rs index 2b56d8e31c4e..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,8 +368,7 @@ impl<'a> TestAuthorityBuilder<'a> { ArchiveReaderBalancer::default(), None, chain_identifier, - pruner_db, - None, + 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 75d943d3db1e..7f180506bb56 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 @@ -28,7 +28,7 @@ fn transaction_input_objects( fx: &iota_types::effects::TransactionEffects, outputs: Option<&crate::transaction_outputs::TransactionOutputs>, object_store: &dyn ObjectStore, - historic_store: Option<&HistoricStore>, + historic_store: &HistoricStore, ) -> IotaResult> { let carried: HashMap = outputs .map(|outputs| { @@ -54,9 +54,7 @@ fn transaction_input_objects( return Ok(object); } historic_store - .map(|store| store.get_object(&key)) - .transpose()? - .flatten() + .get_object(&key)? .ok_or(IotaError::UserInput { error: iota_types::error::UserInputError::ObjectNotFound { object_id, @@ -72,7 +70,7 @@ pub(crate) fn load_checkpoint_data( checkpoint_tx_data: &CheckpointTransactionData, object_store: &dyn ObjectStore, transaction_cache_reader: &dyn TransactionCacheRead, - historic_store: Option<&HistoricStore>, + historic_store: &HistoricStore, ) -> IotaResult { let event_tx_digests = checkpoint_tx_data .effects diff --git a/crates/iota-core/src/checkpoints/checkpoint_executor/mod.rs b/crates/iota-core/src/checkpoints/checkpoint_executor/mod.rs index 9f41246295e0..fd24bc72c140 100644 --- a/crates/iota-core/src/checkpoints/checkpoint_executor/mod.rs +++ b/crates/iota-core/src/checkpoints/checkpoint_executor/mod.rs @@ -669,7 +669,7 @@ impl CheckpointExecutor { tx_data, self.state.get_object_store(), &*self.transaction_cache_reader, - self.state.historic_store.as_deref(), + &self.state.historic_store, ) .expect("failed to load checkpoint data"); @@ -1014,7 +1014,7 @@ impl CheckpointExecutor { &tx_data, self.state.get_object_store(), self.transaction_cache_reader.as_ref(), - self.state.historic_store.as_deref(), + &self.state.historic_store, ) .expect("Failed to load full CheckpointData") }; diff --git a/crates/iota-core/src/db_checkpoint_handler.rs b/crates/iota-core/src/db_checkpoint_handler.rs index ad502954ce88..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,51 +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)); - if self.pruning_config.historic_store.is_some() { - // With the historic store enabled the source node prunes - // continuously by relocation, so there is little to shrink here — - // and delete-mode pruning of the snapshot would strip data from - // its perpetual store without relocating it into the snapshot's - // history, making the uploaded artifact serve less history than - // the source node. Only compact. - info!( - "Skipping pruning of db checkpoint in {:?} for epoch: {epoch}: the historic \ - store keeps the source continuously pruned", - db_path.display() - ); - } else { - 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() - ); - // Relocation must stay disabled here: this prunes a DB checkpoint - // snapshot, which contains no historic store to relocate into. - AuthorityStorePruner::prune_objects_for_eligible_epochs( - &perpetual_db, - &checkpoint_store, - Some(&grpc_indexes_store), - None, - 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() @@ -359,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/storage.rs b/crates/iota-core/src/storage.rs index 5d62d6d7fb65..6fda98731ae5 100644 --- a/crates/iota-core/src/storage.rs +++ b/crates/iota-core/src/storage.rs @@ -45,7 +45,7 @@ pub struct RocksDbStore { /// 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: Option>, + historic_store: Arc, // in memory checkpoint watermark sequence numbers highest_verified_checkpoint: Arc>>, highest_synced_checkpoint: Arc>>, @@ -56,7 +56,7 @@ impl RocksDbStore { cache_traits: ExecutionCacheTraitPointers, committee_store: Arc, checkpoint_store: Arc, - historic_store: Option>, + historic_store: Arc, ) -> Self { Self { cache_traits, @@ -81,10 +81,7 @@ impl RocksDbStore { { return Ok(Some(effects)); } - let Some(historic_store) = &self.historic_store else { - return Ok(None); - }; - historic_store + self.historic_store .get_effects(digest) .map_err(StorageError::custom) } @@ -112,10 +109,8 @@ impl ReadStore for RocksDbStore { { return Ok(Some(checkpoint)); } - let Some(historic_store) = &self.historic_store else { - return Ok(None); - }; - Ok(historic_store + Ok(self + .historic_store .get_checkpoint_by_digest(digest) .map_err(StorageError::custom)? .map(Into::into)) @@ -167,11 +162,8 @@ impl ReadStore for RocksDbStore { // checkpoints in order and buckets expire oldest-first. let Some(historic_lowest) = self .historic_store - .as_ref() - .map(|store| store.lowest_available_checkpoint()) - .transpose() + .lowest_available_checkpoint() .map_err(StorageError::custom)? - .flatten() else { return Ok(after_pruned); }; @@ -262,10 +254,8 @@ impl ReadStore for RocksDbStore { { return Ok(Some(transaction)); } - let Some(historic_store) = &self.historic_store else { - return Ok(None); - }; - Ok(historic_store + Ok(self + .historic_store .get_transaction(digest) .map_err(StorageError::custom)? .map(|transaction| Arc::new(transaction.into()))) @@ -283,16 +273,14 @@ impl ReadStore for RocksDbStore { { return Ok(Some(effects)); } - let Some(historic_store) = &self.historic_store else { - return Ok(None); - }; - let Some(effects_digest) = historic_store + let Some(effects_digest) = self + .historic_store .get_executed_effects(digest) .map_err(StorageError::custom)? else { return Ok(None); }; - historic_store + self.historic_store .get_effects(&effects_digest) .map_err(StorageError::custom) } @@ -309,10 +297,7 @@ impl ReadStore for RocksDbStore { { return Ok(Some(events)); } - let Some(historic_store) = &self.historic_store else { - return Ok(None); - }; - historic_store + self.historic_store .get_events(digest) .map_err(StorageError::custom) } @@ -339,10 +324,7 @@ impl ReadStore for RocksDbStore { { return Ok(Some(contents)); } - let Some(historic_store) = &self.historic_store else { - return Ok(None); - }; - historic_store + self.historic_store .get_checkpoint_contents(digest) .map_err(StorageError::custom) } @@ -497,10 +479,8 @@ impl ObjectStore for GrpcReadStore { // constructed only for the gRPC server, so this fallback is // unreachable from consensus and execution: a live-table miss there // must stay a miss. - let Some(historic_store) = self.state.historic_store.as_ref() else { - return Ok(None); - }; - historic_store + self.state + .historic_store .get_object(&ObjectKey(*object_id, version)) .map_err(StorageError::custom) } @@ -618,14 +598,9 @@ impl GrpcStateReader for GrpcReadStore { .map_err(StorageError::custom)? .map(|cp| cp + 1) .unwrap_or(0); - // With the historic store enabled, exact-version availability extends - // back to the start of the earliest retained epoch bucket. - let Some(earliest_epoch) = self - .state - .historic_store - .as_ref() - .and_then(|store| store.earliest_epoch()) - else { + // 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 { diff --git a/crates/iota-core/src/transaction_outputs.rs b/crates/iota-core/src/transaction_outputs.rs index dc0a6829701e..8baf6b8309dc 100644 --- a/crates/iota-core/src/transaction_outputs.rs +++ b/crates/iota-core/src/transaction_outputs.rs @@ -46,7 +46,6 @@ impl TransactionOutputs { transaction: VerifiedTransaction, effects: TransactionEffects, inner_temporary_store: InnerTemporaryStore, - capture_superseded: bool, ) -> TransactionOutputs { let InnerTemporaryStore { input_objects, @@ -141,19 +140,15 @@ impl TransactionOutputs { // deleted, wrapped and received inputs — all present in // `input_objects` at their input version. Anything absent (or at an // unexpected version) is skipped; the pruner relocates it later. - let superseded = if capture_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() - } else { - Vec::new() - }; + 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), diff --git a/crates/iota-node/src/lib.rs b/crates/iota-node/src/lib.rs index 332670d3e8b7..f97f05ffa032 100644 --- a/crates/iota-node/src/lib.rs +++ b/crates/iota-node/src/lib.rs @@ -34,10 +34,7 @@ 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}, @@ -423,72 +420,26 @@ impl IotaNode { None, )); - let mut historic_store_config = - config.authority_store_pruning_config.historic_store.clone(); - if historic_store_config.is_some() { - // The compaction filter can only keep or remove rows at arbitrary - // compaction times; it would destroy rows before relocation could - // read them. This must be caught before the perpetual DB is - // opened, because the filter is installed at DB-open time. - anyhow::ensure!( - !config - .authority_store_pruning_config - .enable_compaction_filter, - "`historic-store` and `enable-compaction-filter` are mutually exclusive; \ - disable one of them" - ); - if is_validator { - warn!( - "The historic object store is fullnode-only; ignoring the configuration on \ - this validator." - ); - historic_store_config = None; - } - } - let mut pruner_db = None; - if config - .authority_store_pruning_config - .enable_compaction_filter - && historic_store_config.is_none() - { - 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 = if historic_store_config.is_some() { - HistoricStore::extra_column_family_options(&AuthorityPerpetualTables::path( - &config.db_path().join("store"), - )) - } else { - Vec::new() - }; + 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 = historic_store_config - .map(|_| { - HistoricStore::new_shared( - perpetual_tables.database(), - HistoricStoreMetrics::new(&prometheus_registry), - ) - .map(Arc::new) - }) - .transpose()?; + 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."); @@ -710,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(); @@ -747,7 +697,6 @@ impl IotaNode { archive_readers, validator_tx_finalizer, chain_identifier, - pruner_db, historic_store, Some(checkpoint_progress_tracker.clone()), config.policy_config.clone(), @@ -1193,7 +1142,6 @@ impl IotaNode { config: &NodeConfig, prometheus_registry: &Registry, state_snapshot_enabled: bool, - checkpoint_progress_tracker: Option>, ) -> Result<( DBCheckpointConfig, Option>, @@ -1238,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 f53af27cace9..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,20 +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, - None, - pruning_config, + &historic_store, + &pruning_config, metrics, - EPOCH_DURATION_MS_FOR_TESTING, None, ) .await?; @@ -249,15 +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, - 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 6d1a11e00d23..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,13 +646,22 @@ 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(), None)?; + 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(), - None, + historic_store, ); let highest_synced = checkpoint_store @@ -1102,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(), None)?; + 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/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 From e81810132eb6715a235332423413843b01447320 Mon Sep 17 00:00:00 2001 From: muXxer Date: Sun, 12 Jul 2026 19:58:14 +0200 Subject: [PATCH 08/12] feat(core): add a self-retiring migration for pre-existing historic data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nodes upgrading to commit-time relocation start with superseded versions already in the live objects table. A crash-resumable migration drains them once and remembers completion: - A singleton `historic_migration` row in the perpetual store tracks progress (sweeping -> sweep complete -> complete). Databases created at genesis by this version start out complete; only upgraded databases migrate. - The checkpoint walker (the relocation backstop) is the migration core: it drains backlog from the objects watermark and now reports how many superseded versions it actually found. The migration is marked complete once a drain reaches the executed watermark having found nothing, with the legacy sweep finished. - The legacy sweep handles rows the walker can never reach — databases whose checkpoint data below the objects watermark was already pruned before the upgrade. It iterates the live table in bounded slices (cursor persisted atomically with each slice's moves), relocating non-heads into the current epoch's bucket and recording tombstone heads in its expiry list. It keeps slicing even without execution progress and retires permanently once done. - Relocation into a bucket already past the retention horizon deletes outright instead of copying (equivalent to relocating and immediately dropping the bucket), so catching up through deep backlog does not write data just to drop it. - After completion, any superseded version the walker still finds is a commit-time capture miss: `historic_capture_miss_total` is incremented and `debug_fatal!` fires. One quiet release in the wild proves capture exhaustive, after which the walker, the sweep, and the marker can be deleted. `historic_migration_state` exposes progress to operators. --- .../src/authority/authority_store.rs | 10 +- .../src/authority/authority_store_pruner.rs | 553 ++++++++++++++++-- .../src/authority/authority_store_tables.rs | 39 ++ 3 files changed, 547 insertions(+), 55 deletions(-) diff --git a/crates/iota-core/src/authority/authority_store.rs b/crates/iota-core/src/authority/authority_store.rs index 4c7cdfcf6dd6..d84416b7baaf 100644 --- a/crates/iota-core/src/authority/authority_store.rs +++ b/crates/iota-core/src/authority/authority_store.rs @@ -38,7 +38,7 @@ use typed_store::{ }; use super::{ - authority_store_tables::{AuthorityPerpetualTables, LiveObject}, + authority_store_tables::{AuthorityPerpetualTables, HistoricMigrationProgress, LiveObject}, *, }; use crate::{ @@ -279,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 diff --git a/crates/iota-core/src/authority/authority_store_pruner.rs b/crates/iota-core/src/authority/authority_store_pruner.rs index 15d21a10924a..7a32819c18fd 100644 --- a/crates/iota-core/src/authority/authority_store_pruner.rs +++ b/crates/iota-core/src/authority/authority_store_pruner.rs @@ -11,6 +11,7 @@ use std::{ use anyhow::anyhow; 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; @@ -40,9 +41,12 @@ use tokio::{ use tracing::{debug, error, info, warn}; use typed_store::{Map, TypedStoreError, rocks::DBBatch, rocksdb::LiveFile}; -use super::authority_store_tables::AuthorityPerpetualTables; +use super::authority_store_tables::{AuthorityPerpetualTables, HistoricMigrationProgress}; use crate::{ - authority::historic_store::HistoricStore, + authority::{ + authority_store_types::{StoreObject, StoreObjectWrapper}, + historic_store::HistoricStore, + }, checkpoint_progress_tracker::CheckpointProgressTracker, checkpoints::{CheckpointStore, CheckpointWatermark}, grpc_indexes::GrpcIndexesStore, @@ -97,6 +101,12 @@ 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. @@ -137,6 +147,8 @@ pub struct AuthorityStorePruningMetrics { 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 { @@ -206,6 +218,20 @@ impl AuthorityStorePruningMetrics { 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,18 +256,39 @@ pub enum PruningMode { 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 { /// 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, relocation: &HistoricRelocation<'_>, checkpoint_number: CheckpointSequenceNumber, metrics: Arc, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { let _scope = monitored_scope("ObjectsLivePruner"); let mut wb = perpetual_db.objects.batch(); @@ -267,7 +314,7 @@ impl AuthorityStorePruner { .num_pruned_tombstones .inc_by(object_tombstones_to_prune.len() as u64); - Self::relocate_objects( + let found = Self::relocate_objects( &mut wb, perpetual_db, relocation, @@ -277,7 +324,7 @@ impl AuthorityStorePruner { perpetual_db.set_highest_pruned_checkpoint(&mut wb, checkpoint_number)?; metrics.last_pruned_checkpoint.set(checkpoint_number as i64); wb.write()?; - Ok(()) + Ok(found) } /// Moves superseded object versions into the historic epoch bucket @@ -305,7 +352,18 @@ impl AuthorityStorePruner { relocation: &HistoricRelocation<'_>, live_object_keys_to_prune: Vec, tombstone_heads: Vec, - ) -> anyhow::Result<()> { + ) -> 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); + } + let values = perpetual_db .objects .multi_get(live_object_keys_to_prune.iter())?; @@ -325,7 +383,7 @@ impl AuthorityStorePruner { &tombstone_heads, )?; wb.delete_batch(&perpetual_db.objects, rows.iter().map(|(key, _)| *key))?; - Ok(()) + Ok(rows.len()) } /// Stages one epoch-homogeneous batch of checkpoint-keyed history into @@ -451,16 +509,18 @@ impl AuthorityStorePruner { .flat_map(|content| content.iter().map(|tx| tx.transaction)) .collect(); - Self::relocate_checkpoint_data( - &mut perpetual_batch, - perpetual_db, - checkpoint_db, - relocation, - &transactions, - &checkpoints_to_prune, - &checkpoint_content_to_prune, - effects_to_prune, - )?; + 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())?; @@ -540,7 +600,7 @@ impl AuthorityStorePruner { config: &AuthorityStorePruningConfig, metrics: Arc, 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()? @@ -562,6 +622,7 @@ impl AuthorityStorePruner { checkpoint_store, grpc_indexes_store, historic_store, + config.historic_epochs_to_retain, seals_buckets, PruningMode::Objects, 0, @@ -625,6 +686,7 @@ impl AuthorityStorePruner { checkpoint_store, grpc_indexes_store, 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, @@ -636,7 +698,8 @@ impl AuthorityStorePruner { metrics.clone(), progress_tracker, ) - .await + .await?; + Ok(()) } /// Prunes old object versions based on effects from all checkpoints from @@ -646,6 +709,7 @@ impl AuthorityStorePruner { checkpoint_store: &Arc, grpc_indexes_store: Option<&GrpcIndexesStore>, historic_store: &Arc, + historic_epochs_to_retain: u64, seals_buckets: bool, mode: PruningMode, num_epochs_to_retain: u64, @@ -654,11 +718,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()) @@ -700,14 +765,16 @@ impl AuthorityStorePruner { match batch_epoch { Some(epoch) if epoch != checkpoint.epoch() => { if !checkpoints_to_prune.is_empty() { - Self::prune_batch( + total_found += Self::prune_batch( perpetual_db, checkpoint_store, grpc_indexes_store, - &HistoricRelocation { - store: historic_store, - supersession_epoch: epoch, - }, + &HistoricRelocation::new( + historic_store, + epoch, + current_epoch, + historic_epochs_to_retain, + ), mode, checkpoint_number, std::mem::take(&mut checkpoints_to_prune), @@ -777,15 +844,16 @@ impl AuthorityStorePruner { if effects_to_prune.len() >= MAX_TRANSACTIONS_IN_BATCH || checkpoints_to_prune.len() >= MAX_CHECKPOINTS_IN_BATCH { - Self::prune_batch( + total_found += Self::prune_batch( perpetual_db, checkpoint_store, grpc_indexes_store, - &HistoricRelocation { - store: historic_store, - supersession_epoch: batch_epoch - .expect("batch epoch is set before batching"), - }, + &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), @@ -823,14 +891,16 @@ impl AuthorityStorePruner { } if !checkpoints_to_prune.is_empty() { - Self::prune_batch( + total_found += Self::prune_batch( perpetual_db, checkpoint_store, grpc_indexes_store, - &HistoricRelocation { - store: historic_store, - supersession_epoch: batch_epoch.expect("batch epoch is set before batching"), - }, + &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, @@ -858,10 +928,12 @@ impl AuthorityStorePruner { } } - Ok(()) + Ok(total_found) } - /// Dispatches one pruning batch to the mode-specific pruner. + /// 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, @@ -873,7 +945,7 @@ impl AuthorityStorePruner { checkpoint_content_to_prune: Vec, effects_to_prune: Vec, metrics: Arc, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { match mode { PruningMode::Objects => { Self::prune_objects( @@ -885,17 +957,20 @@ impl AuthorityStorePruner { ) .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, - ), + 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) + } } } @@ -935,6 +1010,111 @@ impl AuthorityStorePruner { 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, @@ -1092,7 +1272,22 @@ impl AuthorityStorePruner { // 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 { + 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() @@ -1133,7 +1328,7 @@ impl AuthorityStorePruner { let catching_up = synced_seq.saturating_sub(executed_seq) > PRUNING_DEBOUNCE_MIN_LAG; - if let Err(err) = Self::prune_objects_for_eligible_epochs( + let objects_found = match Self::prune_objects_for_eligible_epochs( &perpetual_db, &checkpoint_store, grpc_indexes_store.as_deref(), @@ -1144,7 +1339,72 @@ impl AuthorityStorePruner { ) .await { - error!("Failed to prune objects: {:?}", err); + 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 @@ -1197,6 +1457,10 @@ impl AuthorityStorePruner { // `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 @@ -1326,7 +1590,7 @@ mod tests { 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}, }, @@ -1462,6 +1726,7 @@ mod tests { &HistoricRelocation { store: &historic, supersession_epoch: 0, + expired: false, }, 0, metrics, @@ -1539,6 +1804,7 @@ mod tests { &HistoricRelocation { store: historic, supersession_epoch, + expired: false, }, checkpoint_number, AuthorityStorePruningMetrics::new_for_test(), @@ -1619,7 +1885,14 @@ mod tests { 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. + // 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 @@ -1925,6 +2198,7 @@ mod tests { &HistoricRelocation { store: &historic, supersession_epoch: committee.epoch, + expired: false, }, 9, vec![ckpt_digest], @@ -2119,6 +2393,7 @@ mod tests { &HistoricRelocation { store: &historic, supersession_epoch: 0, + expired: false, }, 0, metrics, @@ -2196,6 +2471,7 @@ mod tests { &checkpoint_store, None, &historic, + u64::MAX, true, mode, num_epochs_to_retain, @@ -2304,6 +2580,175 @@ mod tests { 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]); + } + + /// 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 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" + ); + } + } + + /// 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 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(); + + 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)); + } + + /// 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); + } + + /// 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() { diff --git a/crates/iota-core/src/authority/authority_store_tables.rs b/crates/iota-core/src/authority/authority_store_tables.rs index e28d13791cdc..8d697bd95888 100644 --- a/crates/iota-core/src/authority/authority_store_tables.rs +++ b/crates/iota-core/src/authority/authority_store_tables.rs @@ -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,6 +161,26 @@ pub struct AuthorityPerpetualTables { pub(crate) object_per_epoch_marker_table: DBMap<(EpochId, ObjectKey), MarkerValue>, } +/// 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. #[derive(Debug, Serialize, Deserialize)] pub(crate) struct TotalIotaSupplyCheck { @@ -424,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()) } From aaa3bda3b59efe09be9be05523951923b98db3c0 Mon Sep 17 00:00:00 2001 From: muXxer Date: Sun, 12 Jul 2026 21:04:49 +0200 Subject: [PATCH 09/12] fix(core): capture pre-images of runtime-loaded objects at commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit-time capture built `superseded` from `input_objects` only, so mutations of runtime-loaded objects (dynamic fields) were never captured — the walker backstop silently relocated every one of them, which would have kept the capture-miss alarm firing forever. Found by that alarm in the e2e suite: the randomness state update mutates a child of the randomness object and panicked `test_validator_tx_finalizer_fastpath_tx`. `commit_transaction` now completes the capture for any superseded version missing from the carried pre-images by reading it back through the object cache — the transaction just read those objects, so the lookups are memory-hot, and a version already relocated is absent from the live view and needs no move. The walker also logs each version it still finds, to make future alarm firings diagnosable. --- crates/iota-core/src/authority.rs | 30 ++++++++++++++++++- .../src/authority/authority_store_pruner.rs | 3 ++ crates/iota-core/src/transaction_outputs.rs | 9 +++--- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/iota-core/src/authority.rs b/crates/iota-core/src/authority.rs index deb6d411cc27..dd753bcf687f 100644 --- a/crates/iota-core/src/authority.rs +++ b/crates/iota-core/src/authority.rs @@ -1726,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())?; diff --git a/crates/iota-core/src/authority/authority_store_pruner.rs b/crates/iota-core/src/authority/authority_store_pruner.rs index 7a32819c18fd..d2c07b25e87c 100644 --- a/crates/iota-core/src/authority/authority_store_pruner.rs +++ b/crates/iota-core/src/authority/authority_store_pruner.rs @@ -372,6 +372,9 @@ impl AuthorityStorePruner { .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"); + } relocation .store diff --git a/crates/iota-core/src/transaction_outputs.rs b/crates/iota-core/src/transaction_outputs.rs index 8baf6b8309dc..00304caaa740 100644 --- a/crates/iota-core/src/transaction_outputs.rs +++ b/crates/iota-core/src/transaction_outputs.rs @@ -136,10 +136,11 @@ impl TransactionOutputs { let wrapped = effects.wrapped().into_iter().map(ObjectKey::from).collect(); - // The pre-images of superseded versions are exactly the mutated, - // deleted, wrapped and received inputs — all present in - // `input_objects` at their input version. Anything absent (or at an - // unexpected version) is skipped; the pruner relocates it later. + // 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)| { From b535fc45efe32e30fe69b822cfe5ca74112b8522 Mon Sep 17 00:00:00 2001 From: muXxer Date: Sun, 12 Jul 2026 21:27:30 +0200 Subject: [PATCH 10/12] fix(core): serve exact-version reads from the historic store in the read API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With relocation at commit time, superseded versions leave the live objects table the moment their checkpoint commits, so the JSON-RPC response assembly for freshly executed transactions (balance and object changes read input pre-images by exact version) failed deterministically with "could not find the referenced object". `read_object_at_version` now falls back to the historic buckets after a live miss — a read-API path only, unreachable from consensus and execution. This also makes `iota_tryGetPastObject` serve versions within the historic retention window. --- crates/iota-core/src/authority.rs | 17 ++++++++++++++--- .../tests/abstract_account_tests.rs | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/iota-core/src/authority.rs b/crates/iota-core/src/authority.rs index dd753bcf687f..549464add734 100644 --- a/crates/iota-core/src/authority.rs +++ b/crates/iota-core/src/authority.rs @@ -4103,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)?; 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(()) } From 43448c4e96632b40777bf7b3ae23b3b9e2ffa471 Mon Sep 17 00:00:00 2001 From: muXxer Date: Sun, 12 Jul 2026 22:01:41 +0200 Subject: [PATCH 11/12] fix(core): rebuild epoch info through the historic store; adapt e2e expectations The from-local-history epoch_info rebuild assembles old epoch-boundary checkpoints, whose transaction, effects, events, and output objects have usually been relocated into the historic buckets by the time a rebuild runs (objects at commit time, the rest by the checkpoint pruner). Every read in the assembly now falls back to the buckets after a live miss, so `Missing` again only means the data aged past historic retention. `object_pruning_test` asserted delete-mode semantics; under relocation the live table keeps each lineage's tombstone head (including a stale `Wrapped` tombstone below a resurrected lineage, removed only by its bucket's expiry) while everything below moved to the buckets. --- .../iota-core/src/checkpoints/epoch_info.rs | 78 +++++++++++++++---- .../tests/object_deletion_tests.rs | 17 ++-- 2 files changed, 77 insertions(+), 18 deletions(-) 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-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; From 34fc5b83bce950aff1b5fe35c2a275f9bcd29e48 Mon Sep 17 00:00:00 2001 From: muXxer Date: Mon, 13 Jul 2026 07:52:39 +0200 Subject: [PATCH 12/12] fix(core): give every response-assembly exact-version read a historic fallback A catching-up devnet node crashed assembling `CheckpointData` for checkpoint 0: the clock object's genesis version had already been superseded by consensus prologues of later checkpoints and relocated out of the live table before the lagging checkpoint-data stage read it as a genesis output. Sweep of every remaining exact-version read that serves responses: - `load_checkpoint_data` output objects (the crash): store read now falls back to the historic buckets when the buffered outputs are gone (replay after restart, or a stage lagging behind later commits). - `AuthorityState::get_transaction_{input,output}_objects` (fullnode execute-transaction responses, validator gRPC responses): a transaction's own commit relocates the versions it superseded, so responses assembled after the commit read through the buckets. - The local transaction-KV serving reads (`get_object`, `multi_get_objects`). Consensus and execution paths keep no fallback: a miss there stays a loud bug. The remaining exact-version readers are pre-commit index post-processing (inputs still live by construction), validator object-info requests (past versions unavailable there today too), and the best-effort forensic dump (tolerates missing rows). --- crates/iota-core/src/authority.rs | 64 ++++++++++++++++--- .../data_ingestion_handler.rs | 28 +++++++- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/crates/iota-core/src/authority.rs b/crates/iota-core/src/authority.rs index 549464add734..72e89fb4cc00 100644 --- a/crates/iota-core/src/authority.rs +++ b/crates/iota-core/src/authority.rs @@ -4326,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> { @@ -6120,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)] @@ -6129,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/checkpoints/checkpoint_executor/data_ingestion_handler.rs b/crates/iota-core/src/checkpoints/checkpoint_executor/data_ingestion_handler.rs index 7f180506bb56..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 @@ -126,8 +126,32 @@ pub(crate) fn load_checkpoint_data( }) }) .collect::>>()?, - None => iota_types::storage::get_transaction_output_objects(object_store, fx) - .map_err(|e| IotaError::Unknown(e.to_string()))?, + // 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 {