From fe2538125b280ed303f138029dcb4d61629318f5 Mon Sep 17 00:00:00 2001 From: Roberto Bayardo Date: Fri, 17 Apr 2026 11:30:14 -0700 Subject: [PATCH 1/8] generalize sync protocol on merkle family --- examples/sync/src/bin/client.rs | 6 +- examples/sync/src/bin/server.rs | 8 +- examples/sync/src/databases/any.rs | 4 +- examples/sync/src/databases/current.rs | 5 +- examples/sync/src/databases/immutable.rs | 6 +- examples/sync/src/databases/keyless.rs | 6 +- examples/sync/src/databases/mod.rs | 7 +- examples/sync/src/net/resolver.rs | 11 +- examples/sync/src/net/wire.rs | 6 +- storage/conformance.toml | 2 +- .../fuzz_targets/current_crash_recovery.rs | 10 +- .../fuzz_targets/current_mmb_prune_grow.rs | 7 +- .../current_ordered_operations.rs | 4 +- .../current_unordered_operations.rs | 4 +- storage/fuzz/fuzz_targets/merkle_journaled.rs | 4 +- .../fuzz/fuzz_targets/qmdb_any_fixed_sync.rs | 7 +- .../fuzz_targets/qmdb_any_variable_sync.rs | 6 +- storage/src/merkle/journaled.rs | 24 +- storage/src/merkle/mmr/journaled.rs | 4 +- storage/src/merkle/mod.rs | 2 +- storage/src/qmdb/any/db.rs | 15 +- storage/src/qmdb/any/ordered/fixed.rs | 4 +- storage/src/qmdb/any/ordered/variable.rs | 4 +- storage/src/qmdb/any/sync/mod.rs | 67 +- storage/src/qmdb/any/sync/tests.rs | 587 ++++++++++++-- storage/src/qmdb/any/unordered/fixed.rs | 4 +- storage/src/qmdb/any/unordered/variable.rs | 4 +- storage/src/qmdb/current/db.rs | 27 +- storage/src/qmdb/current/mod.rs | 125 +++ storage/src/qmdb/current/sync/mod.rs | 155 ++-- storage/src/qmdb/current/sync/tests.rs | 763 +++++++++++++----- storage/src/qmdb/immutable/mod.rs | 9 + storage/src/qmdb/immutable/sync.rs | 27 +- storage/src/qmdb/keyless/mod.rs | 9 + storage/src/qmdb/keyless/sync.rs | 19 +- storage/src/qmdb/sync/database.rs | 21 +- storage/src/qmdb/sync/engine.rs | 100 +-- storage/src/qmdb/sync/error.rs | 28 +- storage/src/qmdb/sync/gaps.rs | 19 +- storage/src/qmdb/sync/journal.rs | 62 +- storage/src/qmdb/sync/mod.rs | 21 +- storage/src/qmdb/sync/requests.rs | 52 +- storage/src/qmdb/sync/resolver.rs | 159 ++-- storage/src/qmdb/sync/target.rs | 89 +- 44 files changed, 1840 insertions(+), 663 deletions(-) diff --git a/examples/sync/src/bin/client.rs b/examples/sync/src/bin/client.rs index 44f700b34b7..d193447b554 100644 --- a/examples/sync/src/bin/client.rs +++ b/examples/sync/src/bin/client.rs @@ -8,7 +8,7 @@ use commonware_codec::{EncodeShared, Read}; use commonware_runtime::{ tokio as tokio_runtime, BufferPooler, Clock, Metrics, Network, Runner, Spawner, Storage, }; -use commonware_storage::qmdb::sync; +use commonware_storage::{mmr, qmdb::sync}; use commonware_sync::{ any, crate_version, current, databases::DatabaseType, immutable, keyless, net::Resolver, Digest, Error, Key, @@ -60,9 +60,9 @@ struct Config { async fn target_update_task( context: E, resolver: Resolver, - update_tx: mpsc::Sender>, + update_tx: mpsc::Sender>, interval_duration: Duration, - initial_target: sync::Target, + initial_target: sync::Target, ) -> Result<(), Error> where E: Clock, diff --git a/examples/sync/src/bin/server.rs b/examples/sync/src/bin/server.rs index fa7fded0f9b..35844482c64 100644 --- a/examples/sync/src/bin/server.rs +++ b/examples/sync/src/bin/server.rs @@ -160,11 +160,11 @@ where state.request_counter.inc(); // Get the current database state - let (root, inactivity_floor, size) = { + let (root, sync_boundary, size) = { let database = state.database.read().await; ( database.root(), - database.inactivity_floor().await, + database.sync_boundary().await, database.size().await, ) }; @@ -172,7 +172,7 @@ where request_id: request.request_id, target: Target { root, - range: non_empty_range!(inactivity_floor, size), + range: non_empty_range!(sync_boundary, size), }, }; @@ -430,7 +430,7 @@ where .collect::(); info!( size = ?database.size().await, - inactivity_floor = ?database.inactivity_floor().await, + sync_boundary = ?database.sync_boundary().await, root = %root_hex, "{} database ready", DB::name() diff --git a/examples/sync/src/databases/any.rs b/examples/sync/src/databases/any.rs index a6f09454147..75256881ae5 100644 --- a/examples/sync/src/databases/any.rs +++ b/examples/sync/src/databases/any.rs @@ -123,8 +123,8 @@ where self.bounds().await.end } - async fn inactivity_floor(&self) -> Location { - self.inactivity_floor_loc() + async fn sync_boundary(&self) -> Location { + self.sync_boundary() } fn historical_proof( diff --git a/examples/sync/src/databases/current.rs b/examples/sync/src/databases/current.rs index ff919e55a52..e9628900e3b 100644 --- a/examples/sync/src/databases/current.rs +++ b/examples/sync/src/databases/current.rs @@ -139,8 +139,9 @@ where self.bounds().await.end } - async fn inactivity_floor(&self) -> Location { - self.inactivity_floor_loc() + async fn sync_boundary(&self) -> Location { + self.sync_boundary() + .expect("sync_boundary should not overflow") } fn historical_proof( diff --git a/examples/sync/src/databases/immutable.rs b/examples/sync/src/databases/immutable.rs index a4ddce18353..52b67bced0b 100644 --- a/examples/sync/src/databases/immutable.rs +++ b/examples/sync/src/databases/immutable.rs @@ -129,10 +129,8 @@ where self.bounds().await.end } - async fn inactivity_floor(&self) -> Location { - // For Immutable databases, all retained operations are active, - // so the inactivity floor equals the pruning boundary. - self.bounds().await.start + async fn sync_boundary(&self) -> Location { + self.sync_boundary().await } fn historical_proof( diff --git a/examples/sync/src/databases/keyless.rs b/examples/sync/src/databases/keyless.rs index dd67840f07f..6642d30bb06 100644 --- a/examples/sync/src/databases/keyless.rs +++ b/examples/sync/src/databases/keyless.rs @@ -126,10 +126,8 @@ where self.bounds().await.end } - async fn inactivity_floor(&self) -> Location { - // Keyless databases have no inactivity floor concept. - // Use the pruning boundary, same as immutable. - self.bounds().await.start + async fn sync_boundary(&self) -> Location { + self.sync_boundary().await } async fn historical_proof( diff --git a/examples/sync/src/databases/mod.rs b/examples/sync/src/databases/mod.rs index b5a854f051f..ae09909632d 100644 --- a/examples/sync/src/databases/mod.rs +++ b/examples/sync/src/databases/mod.rs @@ -75,8 +75,11 @@ pub trait Syncable: Sized { /// Get the total number of operations in the database (including pruned operations). fn size(&self) -> impl Future> + Send; - /// Get the inactivity floor, the location below which all operations are inactive. - fn inactivity_floor(&self) -> impl Future> + Send; + /// Get the most recent location from which this database can safely be synced. + /// + /// Callers constructing a sync target should use this value (or any earlier retained + /// location) as the `range.start`. + fn sync_boundary(&self) -> impl Future> + Send; /// Get historical proof and operations. fn historical_proof( diff --git a/examples/sync/src/net/resolver.rs b/examples/sync/src/net/resolver.rs index 9257e763747..ffcdb0f520d 100644 --- a/examples/sync/src/net/resolver.rs +++ b/examples/sync/src/net/resolver.rs @@ -3,7 +3,10 @@ use crate::net::request_id; use commonware_codec::{EncodeShared, IsUnit, Read}; use commonware_cryptography::Digest; use commonware_runtime::{Network, Spawner}; -use commonware_storage::{mmr::Location, qmdb::sync}; +use commonware_storage::{ + mmr::{self, Location}, + qmdb::sync, +}; use commonware_utils::channel::{mpsc, oneshot}; use std::num::NonZeroU64; @@ -42,7 +45,7 @@ where } /// Returns the current sync target from the server. - pub async fn get_sync_target(&self) -> Result, crate::Error> { + pub async fn get_sync_target(&self) -> Result, crate::Error> { let request_id = self.request_id_generator.next(); let request = wire::Message::GetSyncTargetRequest(wire::GetSyncTargetRequest { request_id }); @@ -75,6 +78,7 @@ where Op::Cfg: IsUnit, D: Digest, { + type Family = mmr::Family; type Digest = D; type Op = Op; type Error = crate::Error; @@ -86,7 +90,8 @@ where max_ops: NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, Self::Error> { + ) -> Result, Self::Error> + { let request_id = self.request_id_generator.next(); let request = wire::Message::GetOperationsRequest(wire::GetOperationsRequest { request_id, diff --git a/examples/sync/src/net/wire.rs b/examples/sync/src/net/wire.rs index 2f0c890bb0b..4e51c4f4ef0 100644 --- a/examples/sync/src/net/wire.rs +++ b/examples/sync/src/net/wire.rs @@ -5,7 +5,7 @@ use commonware_codec::{ use commonware_cryptography::Digest; use commonware_runtime::{Buf, BufMut}; use commonware_storage::{ - mmr::{Location, Proof}, + mmr::{self, Location, Proof}, qmdb::sync::Target, }; use std::num::NonZeroU64; @@ -51,7 +51,7 @@ where D: Digest, { pub request_id: RequestId, - pub target: Target, + pub target: Target, } /// Messages that can be sent over the wire. @@ -334,7 +334,7 @@ where type Cfg = (); fn read_cfg(buf: &mut impl Buf, _: &()) -> Result { let request_id = RequestId::read_cfg(buf, &())?; - let target = Target::::read_cfg(buf, &())?; + let target = Target::::read_cfg(buf, &())?; Ok(Self { request_id, target }) } } diff --git a/storage/conformance.toml b/storage/conformance.toml index d94e681a7d9..5588241d38e 100644 --- a/storage/conformance.toml +++ b/storage/conformance.toml @@ -254,7 +254,7 @@ hash = "c692a20fa40180844888e7a26401f99a45ce3127faeca5f1228a41b454424623" n_cases = 65536 hash = "b41d6c6ec560bde9caf2e206526864c618a0721af367585a1719617ca7ce9291" -["commonware_storage::qmdb::sync::target::tests::conformance::CodecConformance>"] +["commonware_storage::qmdb::sync::target::tests::conformance::CodecConformance>"] n_cases = 65536 hash = "f742d92a0af0af78a9519bf637bc52ea869965a85a84101a4c53f53eb39325ca" diff --git a/storage/fuzz/fuzz_targets/current_crash_recovery.rs b/storage/fuzz/fuzz_targets/current_crash_recovery.rs index a2a7aefcf8e..0378904ea74 100644 --- a/storage/fuzz/fuzz_targets/current_crash_recovery.rs +++ b/storage/fuzz/fuzz_targets/current_crash_recovery.rs @@ -265,8 +265,10 @@ fn fuzz(input: FuzzInput) { { break; } - let floor = db.inactivity_floor_loc(); - if db.prune(floor).await.is_err() { + let Ok(boundary) = db.sync_boundary() else { + break; + }; + if db.prune(boundary).await.is_err() { break; } } @@ -325,7 +327,9 @@ fn fuzz(input: FuzzInput) { } // Verify range proofs over the recovered DB. - let floor = *db.inactivity_floor_loc(); + let floor = *db + .sync_boundary() + .expect("sync_boundary should not overflow"); let size = *db.bounds().await.end; for i in floor..size { let loc = Location::new(i); diff --git a/storage/fuzz/fuzz_targets/current_mmb_prune_grow.rs b/storage/fuzz/fuzz_targets/current_mmb_prune_grow.rs index 87164990861..c5b131aee03 100644 --- a/storage/fuzz/fuzz_targets/current_mmb_prune_grow.rs +++ b/storage/fuzz/fuzz_targets/current_mmb_prune_grow.rs @@ -215,9 +215,10 @@ async fn commit_pending( } async fn prune_to_floor(db: &mut Db, reference_db: &Db, context: &str) { - db.prune(db.inactivity_floor_loc()) - .await - .expect("prune should not fail"); + let boundary = db + .sync_boundary() + .expect("sync_boundary should not overflow"); + db.prune(boundary).await.expect("prune should not fail"); assert_matches_reference(db, reference_db, context).await; } diff --git a/storage/fuzz/fuzz_targets/current_ordered_operations.rs b/storage/fuzz/fuzz_targets/current_ordered_operations.rs index a0fea70119b..501bb2a9bcf 100644 --- a/storage/fuzz/fuzz_targets/current_ordered_operations.rs +++ b/storage/fuzz/fuzz_targets/current_ordered_operations.rs @@ -215,7 +215,7 @@ fn fuzz(data: FuzzInput) { &mut pending_inserts, &mut pending_deletes, ).await; committed_op_count = db.bounds().await.end; - db.prune(db.inactivity_floor_loc()).await.expect("Prune should not fail"); + db.prune(db.sync_boundary().expect("sync_boundary should not overflow")).await.expect("Prune should not fail"); } CurrentOperation::Root => { @@ -243,7 +243,7 @@ fn fuzz(data: FuzzInput) { let current_op_count = db.bounds().await.end; let start_loc = Location::new(start_loc % *current_op_count); - let oldest_loc = db.inactivity_floor_loc(); + let oldest_loc = db.sync_boundary().expect("sync_boundary should not overflow"); if start_loc >= oldest_loc { let (proof, ops, chunks) = db .range_proof(&mut hasher, start_loc, *max_ops) diff --git a/storage/fuzz/fuzz_targets/current_unordered_operations.rs b/storage/fuzz/fuzz_targets/current_unordered_operations.rs index 0ceaaad81df..cf8022cf36b 100644 --- a/storage/fuzz/fuzz_targets/current_unordered_operations.rs +++ b/storage/fuzz/fuzz_targets/current_unordered_operations.rs @@ -197,7 +197,7 @@ fn fuzz(data: FuzzInput) { CurrentOperation::Prune => { commit_pending(&mut db, &mut pending_writes, &mut committed_state, &mut pending_expected).await; committed_op_count = db.bounds().await.end; - db.prune(db.inactivity_floor_loc()).await.expect("Prune should not fail"); + db.prune(db.sync_boundary().expect("sync_boundary should not overflow")).await.expect("Prune should not fail"); } CurrentOperation::Root => { @@ -218,7 +218,7 @@ fn fuzz(data: FuzzInput) { let current_op_count = db.bounds().await.end; let start_loc = Location::new(start_loc % *current_op_count); - let oldest_loc = db.inactivity_floor_loc(); + let oldest_loc = db.sync_boundary().expect("sync_boundary should not overflow"); if start_loc >= oldest_loc { let (proof, ops, chunks) = db .range_proof(&mut hasher, start_loc, *max_ops) diff --git a/storage/fuzz/fuzz_targets/merkle_journaled.rs b/storage/fuzz/fuzz_targets/merkle_journaled.rs index 283a8488e4e..9f7ce7e665e 100644 --- a/storage/fuzz/fuzz_targets/merkle_journaled.rs +++ b/storage/fuzz/fuzz_targets/merkle_journaled.rs @@ -7,7 +7,7 @@ use commonware_storage::merkle::{ hasher::Standard, journaled::Config, mem::Mem, mmb, mmr, Error, Family as MerkleFamily, Location, LocationRangeExt as _, Position, }; -use commonware_utils::{NZUsize, NZU16, NZU64}; +use commonware_utils::{non_empty_range, NZUsize, NZU16, NZU64}; use libfuzzer_sys::fuzz_target; use std::num::NonZeroU16; @@ -356,7 +356,7 @@ fn fuzz_family(input: &FuzzInput, suffix: &str) { let sync_suffix = format!("{suffix}-sync"); let sync_config = SyncConfig:: { config: test_config(&sync_suffix, &context), - range: lower_bound_loc..upper_bound_loc, + range: non_empty_range!(lower_bound_loc, upper_bound_loc), pinned_nodes: None, }; diff --git a/storage/fuzz/fuzz_targets/qmdb_any_fixed_sync.rs b/storage/fuzz/fuzz_targets/qmdb_any_fixed_sync.rs index b413952d86f..fa4a42b122a 100644 --- a/storage/fuzz/fuzz_targets/qmdb_any_fixed_sync.rs +++ b/storage/fuzz/fuzz_targets/qmdb_any_fixed_sync.rs @@ -114,13 +114,14 @@ fn test_config(test_name: &str, pooler: &impl BufferPooler) -> Config { async fn test_sync< R: sync::resolver::Resolver< + Family = Family, Digest = commonware_cryptography::sha256::Digest, Op = FixedOperation, >, >( context: deterministic::Context, resolver: R, - target: sync::Target, + target: sync::Target, fetch_batch_size: u64, test_name: &str, sync_id: usize, @@ -209,7 +210,7 @@ fn fuzz(mut input: FuzzInput) { } Operation::Prune => { - db.prune(db.inactivity_floor_loc()) + db.prune(db.sync_boundary()) .await .expect("Prune should not fail"); } @@ -235,7 +236,7 @@ fn fuzz(mut input: FuzzInput) { db.commit().await.expect("Commit should not fail"); let target = sync::Target { root: db.root(), - range: non_empty_range!(db.inactivity_floor_loc(), db.bounds().await.end), + range: non_empty_range!(db.sync_boundary(), db.bounds().await.end), }; let wrapped_src = Arc::new(db); diff --git a/storage/fuzz/fuzz_targets/qmdb_any_variable_sync.rs b/storage/fuzz/fuzz_targets/qmdb_any_variable_sync.rs index 7d41fb0180d..6ef4be48efd 100644 --- a/storage/fuzz/fuzz_targets/qmdb_any_variable_sync.rs +++ b/storage/fuzz/fuzz_targets/qmdb_any_variable_sync.rs @@ -204,7 +204,7 @@ fn fuzz(input: FuzzInput) { } Operation::Prune => { - db.prune(db.inactivity_floor_loc()) + db.prune(db.sync_boundary()) .await .expect("Prune should not fail"); } @@ -230,7 +230,7 @@ fn fuzz(input: FuzzInput) { db.commit().await.expect("Commit should not fail"); historical_roots.insert(db.bounds().await.end, db.root()); let op_count = db.bounds().await.end; - let oldest_retained_loc = db.inactivity_floor_loc(); + let oldest_retained_loc = db.sync_boundary(); if *start_loc >= oldest_retained_loc && *start_loc < *op_count { if let Ok((proof, log)) = db.proof(*start_loc, *max_ops).await { let root = db.root(); @@ -291,7 +291,7 @@ fn fuzz(input: FuzzInput) { } Operation::InactivityFloorLoc => { - let _ = db.inactivity_floor_loc(); + let _ = db.sync_boundary(); } Operation::OpCount => { diff --git a/storage/src/merkle/journaled.rs b/storage/src/merkle/journaled.rs index 934c508524f..df685c12a07 100644 --- a/storage/src/merkle/journaled.rs +++ b/storage/src/merkle/journaled.rs @@ -27,6 +27,7 @@ use commonware_cryptography::Digest; use commonware_parallel::ThreadPool; use commonware_runtime::{buffer::paged::CacheRef, Clock, Metrics, Storage as RStorage}; use commonware_utils::{ + range::NonEmptyRange, sequence::prefixed_u64::U64, sync::{AsyncMutex, RwLock}, }; @@ -135,7 +136,7 @@ pub struct SyncConfig { pub config: Config, /// Sync range expressed as leaf-aligned bounds. - pub range: std::ops::Range>, + pub range: NonEmptyRange>, /// The pinned nodes the structure needs at the pruning boundary (range start), in the order /// specified by `Family::nodes_to_pin`. If `None`, the pinned nodes are expected to already be @@ -521,8 +522,8 @@ impl Journaled { cfg: SyncConfig, hasher: &impl Hasher, ) -> Result> { - let prune_pos = Position::try_from(cfg.range.start)?; - let end_pos = Position::try_from(cfg.range.end)?; + let prune_pos = Position::try_from(cfg.range.start())?; + let end_pos = Position::try_from(cfg.range.end())?; let journal_cfg = JConfig { partition: cfg.config.journal_partition.clone(), items_per_blob: cfg.config.items_per_blob, @@ -549,7 +550,6 @@ impl Journaled { } // Handle existing data vs sync range. - assert!(!cfg.range.is_empty(), "range must not be empty"); if journal_size > *end_pos { return Err(crate::journal::Error::ItemOutOfRange(*journal_size).into()); } @@ -570,7 +570,7 @@ impl Journaled { let pruning_boundary_key = U64::new(PRUNED_TO_PREFIX, 0); metadata.put( pruning_boundary_key, - cfg.range.start.as_u64().to_be_bytes().into(), + cfg.range.start().as_u64().to_be_bytes().into(), ); // Write the required pinned nodes to metadata. @@ -1149,7 +1149,7 @@ mod tests { }; use commonware_macros::test_traced; use commonware_runtime::{buffer::paged::CacheRef, deterministic, BufferPooler, Runner}; - use commonware_utils::{sequence::prefixed_u64::U64, NZUsize, NZU16, NZU64}; + use commonware_utils::{non_empty_range, sequence::prefixed_u64::U64, NZUsize, NZU16, NZU64}; use std::{ collections::BTreeMap, num::{NonZeroU16, NonZeroUsize}, @@ -2066,7 +2066,7 @@ mod tests { // Test fresh start scenario with completely new structure (no existing data) let sync_cfg = SyncConfig:: { config: test_config(&context), - range: Location::::new(0)..Location::::new(52), + range: non_empty_range!(Location::::new(0), Location::::new(52)), pinned_nodes: None, }; @@ -2143,7 +2143,7 @@ mod tests { } let sync_cfg = SyncConfig:: { config: test_config(&context), - range: lower_bound_loc..upper_bound_loc, + range: non_empty_range!(lower_bound_loc, upper_bound_loc), pinned_nodes: None, }; @@ -2225,7 +2225,7 @@ mod tests { let sync_cfg = SyncConfig:: { config: test_config(&context), - range: lower_bound_loc..upper_bound_loc, + range: non_empty_range!(lower_bound_loc, upper_bound_loc), pinned_nodes: None, }; @@ -2275,7 +2275,7 @@ mod tests { let sync_cfg = SyncConfig:: { config: test_config(&context), - range: Location::::new(6)..Location::::new(20), + range: non_empty_range!(Location::::new(6), Location::::new(20)), pinned_nodes: Some(vec![test_digest(1), test_digest(2), test_digest(3)]), }; @@ -2479,7 +2479,7 @@ mod tests { let prune_loc = Location::::new(32); let sync_cfg = SyncConfig:: { config: cfg, - range: prune_loc..Location::::new(128), + range: non_empty_range!(prune_loc, Location::::new(128)), pinned_nodes: None, // Force init_sync to compute pinned nodes from journal }; @@ -3074,7 +3074,7 @@ mod tests { // init_sync should recover by rewinding to the last valid size. let sync_cfg = SyncConfig:: { config: test_config(&context), - range: Location::::new(0)..Location::::new(100), + range: non_empty_range!(Location::::new(0), Location::::new(100)), pinned_nodes: None, }; let sync_mmr = diff --git a/storage/src/merkle/mmr/journaled.rs b/storage/src/merkle/mmr/journaled.rs index afd0721b1c8..694b1ddee03 100644 --- a/storage/src/merkle/mmr/journaled.rs +++ b/storage/src/merkle/mmr/journaled.rs @@ -33,7 +33,7 @@ mod tests { use commonware_runtime::{ buffer::paged::CacheRef, deterministic, BufferPooler, Metrics, Runner, }; - use commonware_utils::{NZUsize, NZU16, NZU64}; + use commonware_utils::{non_empty_range, NZUsize, NZU16, NZU64}; use std::num::{NonZeroU16, NonZeroUsize}; fn test_digest(v: usize) -> Digest { @@ -339,7 +339,7 @@ mod tests { // "fresh start" path (clear_to_size). let sync_cfg = SyncConfig:: { config: test_config(&context), - range: Location::new(100)..Location::new(200), + range: non_empty_range!(Location::new(100), Location::new(200)), pinned_nodes: Some(pinned), }; let mut sync_mmr = Mmr::init_sync(context.with_label("sync"), sync_cfg, &hasher) diff --git a/storage/src/merkle/mod.rs b/storage/src/merkle/mod.rs index f815152499d..580e8cd549e 100644 --- a/storage/src/merkle/mod.rs +++ b/storage/src/merkle/mod.rs @@ -67,7 +67,7 @@ pub trait Family: Copy + Clone + Debug + Default + Send + Sync + 'static { /// Return the peaks of a structure with the given `size` as `(position, height)` pairs /// in canonical oldest-to-newest order (suitable for /// [`Hasher::root`](crate::merkle::hasher::Hasher::root)). - fn peaks(size: Position) -> impl Iterator, u32)>; + fn peaks(size: Position) -> impl Iterator, u32)> + Send; /// Compute positions of nodes that must be pinned when pruning to `prune_loc`. /// diff --git a/storage/src/qmdb/any/db.rs b/storage/src/qmdb/any/db.rs index fb2ebfb1f53..f9eac93e904 100644 --- a/storage/src/qmdb/any/db.rs +++ b/storage/src/qmdb/any/db.rs @@ -100,7 +100,20 @@ where { /// Return the inactivity floor location. This is the location before which all operations are /// known to be inactive. Operations before this point can be safely pruned. - pub const fn inactivity_floor_loc(&self) -> Location { + /// + /// This is an implementation detail of the activity tracking; external callers should + /// prefer [`Self::sync_boundary`] when constructing a sync target. + pub(crate) const fn inactivity_floor_loc(&self) -> Location { + self.inactivity_floor_loc + } + + /// Return the most recent location from which this database can safely be synced. + /// + /// Callers constructing a sync [`Target`](crate::qmdb::sync::Target) may use this value, or + /// any earlier retained location, as `range.start`. For `any` databases this equals the + /// inactivity floor; the receiver's reconstruction does not require chunk alignment, so any + /// retained earlier location is also valid. + pub const fn sync_boundary(&self) -> Location { self.inactivity_floor_loc } diff --git a/storage/src/qmdb/any/ordered/fixed.rs b/storage/src/qmdb/any/ordered/fixed.rs index 7e686abdea6..088456f6cf3 100644 --- a/storage/src/qmdb/any/ordered/fixed.rs +++ b/storage/src/qmdb/any/ordered/fixed.rs @@ -1666,9 +1666,9 @@ pub(crate) mod test { type TestMmr = Mmr; impl FromSyncTestable for AnyTest { - type Mmr = TestMmr; + type Merkle = TestMmr; - fn into_log_components(self) -> (Self::Mmr, Self::Journal) { + fn into_log_components(self) -> (Self::Merkle, Self::Journal) { (self.log.merkle, self.log.journal) } diff --git a/storage/src/qmdb/any/ordered/variable.rs b/storage/src/qmdb/any/ordered/variable.rs index 1eff34ac494..51c5f92d526 100644 --- a/storage/src/qmdb/any/ordered/variable.rs +++ b/storage/src/qmdb/any/ordered/variable.rs @@ -605,9 +605,9 @@ pub(crate) mod test { type TestMmr = Mmr; impl FromSyncTestable for AnyTest { - type Mmr = TestMmr; + type Merkle = TestMmr; - fn into_log_components(self) -> (Self::Mmr, Self::Journal) { + fn into_log_components(self) -> (Self::Merkle, Self::Journal) { (self.log.merkle, self.log.journal) } diff --git a/storage/src/qmdb/any/sync/mod.rs b/storage/src/qmdb/any/sync/mod.rs index 57c0ca7549d..394f6cebadf 100644 --- a/storage/src/qmdb/any/sync/mod.rs +++ b/storage/src/qmdb/any/sync/mod.rs @@ -8,7 +8,7 @@ use crate::{ authenticated, contiguous::{fixed, variable, Mutable}, }, - merkle::mmr::{self, journaled, Location, StandardHasher}, + merkle::{self, hasher::Standard as StandardHasher, journaled, Location}, qmdb::{ self, any::{ @@ -41,8 +41,7 @@ use crate::{ }; use commonware_codec::{CodecShared, Read as CodecRead}; use commonware_cryptography::Hasher; -use commonware_utils::Array; -use std::ops::Range; +use commonware_utils::{range::NonEmptyRange, Array}; #[cfg(test)] pub(crate) mod tests; @@ -61,17 +60,18 @@ pub(crate) mod tests; /// prune their own database past the committed floor) can cause a later /// [`qmdb::sync::Database::from_sync_result`] rebuild to fail with `MissingNode` /// even though this function returned `true`. -pub async fn has_local_target_state( +pub async fn has_local_target_state( context: E, merkle_config: journaled::Config, - target: &qmdb::sync::Target, + target: &qmdb::sync::Target, ) -> bool where + F: merkle::Family, E: Context, H: Hasher, { let hasher = StandardHasher::::new(); - let peek = journaled::Mmr::<_, H::Digest>::peek_root( + let peek = journaled::Journaled::::peek_root( context.with_label("local_target_probe"), merkle_config, &hasher, @@ -87,19 +87,20 @@ where } /// Shared helper to build a [Db] from sync components. -async fn build_db( +async fn build_db( context: E, - mmr_config: journaled::Config, + merkle_config: journaled::Config, log: C, translator: T, pinned_nodes: Option>, - range: Range, + range: NonEmptyRange>, apply_batch_size: usize, -) -> Result, qmdb::Error> +) -> Result, qmdb::Error> where + F: merkle::Family, E: Context, - O: Operation + Committable + CodecShared + Send + Sync + 'static, - I: IndexFactory, + O: Operation + Committable + CodecShared + Send + Sync + 'static, + I: IndexFactory>, H: Hasher, U: Send + Sync + 'static, T: Translator, @@ -107,10 +108,10 @@ where { let hasher = StandardHasher::::new(); - let mmr = crate::mmr::journaled::Mmr::init_sync( - context.with_label("mmr"), - crate::mmr::journaled::SyncConfig { - config: mmr_config, + let merkle = journaled::Journaled::::init_sync( + context.with_label("merkle"), + journaled::SyncConfig { + config: merkle_config, range: range.clone(), pinned_nodes, }, @@ -120,14 +121,14 @@ where let index = I::new(context.with_label("index"), translator); - let log = authenticated::Journal::::from_components( - mmr, + let log = authenticated::Journal::::from_components( + merkle, log, hasher, apply_batch_size as u64, ) .await?; - let db = Db::from_components(range.start, log, index).await?; + let db = Db::from_components(range.start(), log, index).await?; Ok(db) } @@ -137,8 +138,9 @@ macro_rules! impl_sync_database { $journal:ty, $config:ty, $key_bound:path, $value_bound:ident $(; $($where_extra:tt)+)?) => { - impl qmdb::sync::Database for $db + impl qmdb::sync::Database for $db where + F: merkle::Family, E: Context, K: $key_bound, V: $value_bound + 'static, @@ -146,8 +148,9 @@ macro_rules! impl_sync_database { T: Translator, $($($where_extra)+)? { + type Family = F; type Context = E; - type Op = $op; + type Op = $op; type Journal = $journal; type Hasher = H; type Config = $config; @@ -158,14 +161,14 @@ macro_rules! impl_sync_database { config: Self::Config, log: Self::Journal, pinned_nodes: Option>, - range: Range, + range: NonEmptyRange>, apply_batch_size: usize, - ) -> Result> { - let mmr_config = config.merkle_config.clone(); + ) -> Result> { + let merkle_config = config.merkle_config.clone(); let translator = config.translator.clone(); - build_db::<_, Self::Op, _, H, $update, _, T>( + build_db::, _, T>( context, - mmr_config, + merkle_config, log, translator, pinned_nodes, @@ -178,9 +181,9 @@ macro_rules! impl_sync_database { async fn has_local_target_state( context: Self::Context, config: &Self::Config, - target: &qmdb::sync::Target, + target: &qmdb::sync::Target, ) -> bool { - qmdb::any::sync::has_local_target_state::<_, H>( + qmdb::any::sync::has_local_target_state::( context, config.merkle_config.clone(), target, @@ -204,9 +207,9 @@ impl_sync_database!( impl_sync_database!( UnorderedVariableDb, UnorderedVariableOp, UnorderedVariableUpdate, variable::Journal, - VariableConfig as CodecRead>::Cfg>, + VariableConfig as CodecRead>::Cfg>, Key, VariableValue; - UnorderedVariableOp: CodecShared + UnorderedVariableOp: CodecShared ); impl_sync_database!( @@ -218,7 +221,7 @@ impl_sync_database!( impl_sync_database!( OrderedVariableDb, OrderedVariableOp, OrderedVariableUpdate, variable::Journal, - VariableConfig as CodecRead>::Cfg>, + VariableConfig as CodecRead>::Cfg>, Key, VariableValue; - OrderedVariableOp: CodecShared + OrderedVariableOp: CodecShared ); diff --git a/storage/src/qmdb/any/sync/tests.rs b/storage/src/qmdb/any/sync/tests.rs index ce59e87d029..4f73ca312d2 100644 --- a/storage/src/qmdb/any/sync/tests.rs +++ b/storage/src/qmdb/any/sync/tests.rs @@ -6,7 +6,7 @@ use crate::{ journal::contiguous::Contiguous, - merkle::{mmr, mmr::Location}, + merkle::{self, Location}, qmdb::{ self, any::traits::DbAny, @@ -53,40 +53,56 @@ pub(crate) type ConfigOf = as qmdb::sync::Database>::Config; /// Type alias for the journal type of a harness. pub(crate) type JournalOf = as qmdb::sync::Database>::Journal; +/// Type alias for the merkle family used by a harness. +pub(crate) type FamilyOf = as qmdb::sync::Database>::Family; + /// Trait for cleanup operations in tests. pub(crate) trait Destructible { + type Family: merkle::Family; + fn destroy( self, - ) -> impl std::future::Future>> + Send; + ) -> impl std::future::Future>> + Send; } -// Implement Destructible for the concrete MMR type used in tests. +// Implement Destructible once for the generic journaled Merkle type used in tests. // This is here (rather than in fixed/variable modules) to avoid duplicate implementations. -impl Destructible for crate::mmr::journaled::Mmr { - async fn destroy(self) -> Result<(), qmdb::Error> { +impl Destructible + for crate::merkle::journaled::Journaled +{ + type Family = F; + + async fn destroy(self) -> Result<(), qmdb::Error> { self.destroy().await.map_err(qmdb::Error::Merkle) } } /// Trait providing internal access for from_sync_result tests. pub(crate) trait FromSyncTestable: qmdb::sync::Database { - type Mmr: Destructible + Send; + type Merkle: Destructible + Send; - /// Get the MMR and journal from the database - fn into_log_components(self) -> (Self::Mmr, Self::Journal); + /// Get the Merkle structure and journal from the database. + fn into_log_components(self) -> (Self::Merkle, Self::Journal); /// Get the pinned nodes at a given location fn pinned_nodes_at( &self, - loc: Location, + loc: Location, ) -> impl std::future::Future> + Send; } /// Harness for sync tests. pub(crate) trait SyncTestHarness: Sized + 'static { + /// The merkle family the database under test uses. + type Family: merkle::Family; + /// The database type being tested. - type Db: qmdb::sync::Database - + DbAny; + type Db: qmdb::sync::Database< + Family = Self::Family, + Context = deterministic::Context, + Digest = Digest, + Config: Clone, + > + DbAny; /// Return the root the sync engine targets. fn sync_target_root(db: &Self::Db) -> Digest; @@ -120,7 +136,7 @@ pub(crate) trait SyncTestHarness: Sized + 'static { /// Test that empty operations arrays fetched do not cause panics when stored and applied pub(crate) fn test_sync_empty_operations_no_panic() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -165,13 +181,14 @@ where /// Test that resolver failure is handled correctly pub(crate) fn test_sync_resolver_fails() where - resolver::tests::FailResolver, Digest>: Resolver, Digest = Digest>, + resolver::tests::FailResolver, OpOf, Digest>: + Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { - let resolver = resolver::tests::FailResolver::, Digest>::new(); + let resolver = resolver::tests::FailResolver::, OpOf, Digest>::new(); let target_root = Digest::from([0; 32]); let db_config = H::config(&context.next_u64().to_string(), &context); @@ -200,7 +217,7 @@ where /// Test basic sync functionality with various batch sizes pub(crate) fn test_sync(target_db_ops: usize, fetch_batch_size: NonZeroU64) where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -283,8 +300,8 @@ where /// Test syncing to a subset of the target database (target has additional ops beyond sync range) pub(crate) fn test_sync_subset_of_target_database(target_db_ops: usize) where - Arc>: Resolver, Digest = Digest>, - OpOf: Encode + Clone + OperationTrait, + Arc>: Resolver, Op = OpOf, Digest = Digest>, + OpOf: Encode + Clone + OperationTrait, Key = Digest>, JournalOf: Contiguous, { let executor = deterministic::Runner::default(); @@ -349,8 +366,8 @@ where /// Tests the scenario where sync_db already has partial data and needs to sync additional ops. pub(crate) fn test_sync_use_existing_db_partial_match(original_ops: usize) where - Arc>: Resolver, Digest = Digest>, - OpOf: Encode + Clone + OperationTrait, + Arc>: Resolver, Op = OpOf, Digest = Digest>, + OpOf: Encode + Clone + OperationTrait, Key = Digest>, JournalOf: Contiguous, { let executor = deterministic::Runner::default(); @@ -444,8 +461,9 @@ where /// Uses FailResolver to verify that no network requests are made since data already exists. pub(crate) fn test_sync_use_existing_db_exact_match(num_ops: usize) where - resolver::tests::FailResolver, Digest>: Resolver, Digest = Digest>, - OpOf: Encode + Clone + OperationTrait, + resolver::tests::FailResolver, OpOf, Digest>: + Resolver, Op = OpOf, Digest = Digest>, + OpOf: Encode + Clone + OperationTrait, Key = Digest>, JournalOf: Contiguous, { let executor = deterministic::Runner::default(); @@ -487,7 +505,7 @@ where // sync_db should never ask the resolver for operations // because it is already complete. Use a resolver that always fails // to ensure that it's not being used. - let resolver = resolver::tests::FailResolver::, Digest>::new(); + let resolver = resolver::tests::FailResolver::, OpOf, Digest>::new(); let config = Config { db_config: sync_config, // Use same config to access same partitions fetch_batch_size: NZU64!(10), @@ -532,7 +550,7 @@ where /// Test that the client fails to sync if the lower bound is decreased via target update. pub(crate) fn test_target_update_lower_bound_decrease() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -601,7 +619,7 @@ where /// Test that the client fails to sync if the upper bound is decreased via target update. pub(crate) fn test_target_update_upper_bound_decrease() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -670,7 +688,7 @@ where /// Test that the client succeeds when bounds are updated (increased). pub(crate) fn test_target_update_bounds_increase() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode + Clone, JournalOf: Contiguous, { @@ -755,7 +773,7 @@ where /// Test that target updates can be sent even after the client is done (no panic). pub(crate) fn test_target_update_on_done_client() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -824,7 +842,7 @@ where /// Test that explicit finish control waits for a finish signal even after reaching target. pub(crate) fn test_sync_waits_for_explicit_finish() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -927,7 +945,7 @@ where /// Test that a finish signal received before target completion still allows full sync. pub(crate) fn test_sync_handles_early_finish_signal() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -990,7 +1008,7 @@ where /// Test that dropping finish sender without sending is treated as an error. pub(crate) fn test_sync_fails_when_finish_sender_dropped() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -1039,7 +1057,7 @@ where /// Test that dropping reached-target receiver does not fail sync. pub(crate) fn test_sync_allows_dropped_reached_target_receiver() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -1093,7 +1111,8 @@ pub(crate) fn test_target_update_during_sync( initial_ops: usize, additional_ops: usize, ) where - Arc>>>: Resolver, Digest = Digest>, + Arc>>>: + Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode + Clone, JournalOf: Contiguous, { @@ -1204,7 +1223,7 @@ pub(crate) fn test_target_update_during_sync( /// Test demonstrating that a synced database can be reopened and retain its state. pub(crate) fn test_sync_database_persistence() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode + Clone, JournalOf: Contiguous, { @@ -1278,7 +1297,7 @@ where /// Test post-sync usability: after syncing, the database supports normal operations. pub(crate) fn test_sync_post_sync_usability() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -1360,7 +1379,7 @@ where db_config, journal, Some(pinned_nodes), - sync_lower_bound..sync_upper_bound, + non_empty_range!(sync_lower_bound, sync_upper_bound), 1024, ) .await @@ -1436,7 +1455,7 @@ where sync_db_config, journal, Some(pinned_nodes), - sync_lower_bound..sync_upper_bound, + non_empty_range!(sync_lower_bound, sync_upper_bound), 1024, ) .await @@ -1498,7 +1517,7 @@ where new_db_config, journal, Some(pinned_nodes), - lower_bound..upper_bound, + non_empty_range!(lower_bound, upper_bound), 1024, ) .await @@ -1543,7 +1562,7 @@ where new_db_config, journal, None, - Location::new(0)..Location::new(1), + non_empty_range!(Location::new(0), Location::new(1)), 1024, ) .await @@ -1574,19 +1593,23 @@ struct CorruptFirstPinnedNodesResolver { corrupted: Arc, } -impl> Resolver for CorruptFirstPinnedNodesResolver { +impl Resolver for CorruptFirstPinnedNodesResolver +where + R: Resolver, +{ + type Family = R::Family; type Digest = Digest; type Op = R::Op; type Error = R::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, cancel_rx: oneshot::Receiver<()>, - ) -> Result, Self::Error> { + ) -> Result, Self::Error> { let mut result = self .inner .get_operations( @@ -1617,7 +1640,7 @@ impl> Resolver for CorruptFirstPinnedNodesResolver< /// succeeds on retry when the resolver returns correct data. pub(crate) fn test_sync_retries_bad_pinned_nodes() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -1671,28 +1694,29 @@ where /// A resolver wrapper that replays the first fresh boundary request against the retained /// historical root, then blocks the retry until the test releases it. #[derive(Clone)] -struct ReplayFreshBoundaryResolver { +struct ReplayFreshBoundaryResolver> { inner: R, - historical_target_size: Location, - boundary_start: Location, + historical_target_size: Location, + boundary_start: Location, release_historical_gap: Arc>>>, release_boundary_retry: Arc>>>, boundary_attempts: Arc, } impl> Resolver for ReplayFreshBoundaryResolver { + type Family = R::Family; type Digest = Digest; type Op = R::Op; type Error = R::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, cancel_rx: oneshot::Receiver<()>, - ) -> Result, Self::Error> { + ) -> Result, Self::Error> { if op_count == self.historical_target_size { if include_pinned_nodes { let _ = cancel_rx.await; @@ -1753,7 +1777,7 @@ impl> Resolver for ReplayFreshBoundaryResolver { /// boundary retry is still outstanding. pub(crate) fn test_sync_waits_for_boundary_retry_after_target_update() where - Arc>: Resolver, Digest = Digest>, + Arc>: Resolver, Op = OpOf, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -1888,15 +1912,126 @@ where mod harnesses { use super::SyncTestHarness; - use crate::{qmdb::any::value::VariableEncoding, translator::TwoCap}; + use crate::{ + merkle::{self, mmb}, + qmdb::any::value::VariableEncoding, + translator::TwoCap, + }; use commonware_cryptography::sha256::Digest; + use commonware_math::algebra::Random; use commonware_runtime::{deterministic::Context, BufferPooler}; + use commonware_utils::test_rng_seeded; + use rand::RngCore; + + // ===== Family-generic op creation helpers ===== + // + // `Operation` is phantom in F for Update/Delete variants, so ops + // are structurally identical across families. + + fn create_ordered_fixed_ops( + n: usize, + seed: u64, + ) -> Vec> { + use crate::qmdb::any::operation::{update::Ordered as Update, Operation}; + let mut rng = test_rng_seeded(seed); + let mut prev_key = Digest::random(&mut rng); + let mut ops = Vec::new(); + for i in 0..n { + if i % 10 == 0 && i > 0 { + ops.push(Operation::Delete(prev_key)); + } else { + let key = Digest::random(&mut rng); + let next_key = Digest::random(&mut rng); + let value = Digest::random(&mut rng); + ops.push(Operation::Update(Update { + key, + value, + next_key, + })); + prev_key = key; + } + } + ops + } + + fn create_unordered_fixed_ops( + n: usize, + seed: u64, + ) -> Vec> { + use crate::qmdb::any::operation::{update::Unordered as Update, Operation}; + let mut rng = test_rng_seeded(seed); + let mut prev_key = Digest::random(&mut rng); + let mut ops = Vec::new(); + for i in 0..n { + if i % 10 == 0 && i > 0 { + ops.push(Operation::Delete(prev_key)); + } else { + let key = Digest::random(&mut rng); + let value = Digest::random(&mut rng); + ops.push(Operation::Update(Update(key, value))); + prev_key = key; + } + } + ops + } + + fn create_ordered_variable_ops( + n: usize, + seed: u64, + ) -> Vec>> { + use crate::qmdb::any::operation::{update::Ordered as Update, Operation}; + let mut rng = test_rng_seeded(seed); + let mut prev_key = Digest::random(&mut rng); + let mut ops = Vec::new(); + for i in 0..n { + if i % 10 == 0 && i > 0 { + ops.push(Operation::Delete(prev_key)); + } else { + let key = Digest::random(&mut rng); + let next_key = Digest::random(&mut rng); + let len = ((rng.next_u64() % 13) + 7) as usize; + let value = vec![(rng.next_u64() % 255) as u8; len]; + ops.push(Operation::Update(Update { + key, + value, + next_key, + })); + prev_key = key; + } + } + ops + } + + fn create_unordered_variable_ops( + n: usize, + seed: u64, + ) -> Vec>> { + use crate::qmdb::any::operation::{update::Unordered as Update, Operation}; + let mut rng = test_rng_seeded(seed); + let mut prev_key = Digest::random(&mut rng); + let mut ops = Vec::new(); + for i in 0..n { + if i % 10 == 0 && i > 0 { + ops.push(Operation::Delete(prev_key)); + } else { + let key = Digest::random(&mut rng); + let len = ((rng.next_u64() % 13) + 7) as usize; + let value = vec![(rng.next_u64() % 255) as u8; len]; + ops.push(Operation::Update(Update(key, value))); + prev_key = key; + } + } + ops + } + + // ===== MMR harnesses (existing, unchanged) ===== // ----- Ordered/Fixed ----- pub struct OrderedFixedHarness; impl SyncTestHarness for OrderedFixedHarness { + type Family = crate::mmr::Family; type Db = crate::qmdb::any::ordered::fixed::test::AnyTest; fn sync_target_root(db: &Self::Db) -> Digest { @@ -1954,6 +2089,7 @@ mod harnesses { pub struct OrderedVariableHarness; impl SyncTestHarness for OrderedVariableHarness { + type Family = crate::mmr::Family; type Db = crate::qmdb::any::ordered::variable::test::AnyTest; fn sync_target_root(db: &Self::Db) -> Digest { @@ -2018,6 +2154,7 @@ mod harnesses { pub struct UnorderedFixedHarness; impl SyncTestHarness for UnorderedFixedHarness { + type Family = crate::mmr::Family; type Db = crate::qmdb::any::unordered::fixed::test::AnyTest; fn sync_target_root(db: &Self::Db) -> Digest { @@ -2075,6 +2212,7 @@ mod harnesses { pub struct UnorderedVariableHarness; impl SyncTestHarness for UnorderedVariableHarness { + type Family = crate::mmr::Family; type Db = crate::qmdb::any::unordered::variable::test::AnyTest; fn sync_target_root(db: &Self::Db) -> Digest { @@ -2138,6 +2276,323 @@ mod harnesses { db } } + + // ===== MMB harnesses ===== + + // ----- Ordered/Fixed MMB ----- + + pub struct OrderedFixedMmbHarness; + + impl SyncTestHarness for OrderedFixedMmbHarness { + type Family = mmb::Family; + type Db = crate::qmdb::any::ordered::fixed::Db< + mmb::Family, + Context, + Digest, + Digest, + commonware_cryptography::Sha256, + TwoCap, + >; + + fn sync_target_root(db: &Self::Db) -> Digest { + db.root() + } + + fn config( + suffix: &str, + pooler: &impl BufferPooler, + ) -> crate::qmdb::any::FixedConfig { + crate::qmdb::any::test::fixed_db_config(suffix, pooler) + } + + fn create_ops( + n: usize, + ) -> Vec> { + create_ordered_fixed_ops(n, 0) + } + + fn create_ops_seeded( + n: usize, + seed: u64, + ) -> Vec> { + create_ordered_fixed_ops(n, seed) + } + + async fn init_db(mut ctx: Context) -> Self::Db { + let seed = ctx.next_u64(); + let cfg = crate::qmdb::any::test::fixed_db_config::(&seed.to_string(), &ctx); + Self::Db::init(ctx, cfg).await.unwrap() + } + + async fn init_db_with_config( + ctx: Context, + config: crate::qmdb::any::FixedConfig, + ) -> Self::Db { + Self::Db::init(ctx, config).await.unwrap() + } + + async fn apply_ops( + mut db: Self::Db, + ops: Vec>, + ) -> Self::Db { + use crate::qmdb::any::operation::Operation; + let mut batch = db.new_batch(); + for op in ops { + match op { + Operation::Update(data) => { + batch = batch.write(data.key, Some(data.value)); + } + Operation::Delete(key) => { + batch = batch.write(key, None); + } + Operation::CommitFloor(_, _) => {} + } + } + let merkleized = batch.merkleize(&db, None::).await.unwrap(); + db.apply_batch(merkleized).await.unwrap(); + db + } + } + + // ----- Ordered/Variable MMB ----- + + pub struct OrderedVariableMmbHarness; + + impl SyncTestHarness for OrderedVariableMmbHarness { + type Family = mmb::Family; + type Db = crate::qmdb::any::ordered::variable::Db< + mmb::Family, + Context, + Digest, + Vec, + commonware_cryptography::Sha256, + TwoCap, + >; + + fn sync_target_root(db: &Self::Db) -> Digest { + db.root() + } + + fn config( + suffix: &str, + pooler: &impl BufferPooler, + ) -> crate::qmdb::any::ordered::variable::test::VarConfig { + crate::qmdb::any::ordered::variable::test::create_test_config( + suffix.parse().unwrap_or(0), + pooler, + ) + } + + fn create_ops( + n: usize, + ) -> Vec>> + { + create_ordered_variable_ops(n, 0) + } + + fn create_ops_seeded( + n: usize, + seed: u64, + ) -> Vec>> + { + create_ordered_variable_ops(n, seed) + } + + async fn init_db(mut ctx: Context) -> Self::Db { + let seed = ctx.next_u64(); + let config = crate::qmdb::any::ordered::variable::test::create_test_config(seed, &ctx); + Self::Db::init(ctx, config).await.unwrap() + } + + async fn init_db_with_config( + ctx: Context, + config: crate::qmdb::any::ordered::variable::test::VarConfig, + ) -> Self::Db { + Self::Db::init(ctx, config).await.unwrap() + } + + async fn apply_ops( + mut db: Self::Db, + ops: Vec>>, + ) -> Self::Db { + use crate::qmdb::any::operation::Operation; + let mut batch = db.new_batch(); + for op in ops { + match op { + Operation::Update(data) => { + batch = batch.write(data.key, Some(data.value)); + } + Operation::Delete(key) => { + batch = batch.write(key, None); + } + Operation::CommitFloor(_, _) => {} + } + } + let merkleized = batch.merkleize(&db, None::>).await.unwrap(); + db.apply_batch(merkleized).await.unwrap(); + db + } + } + + // ----- Unordered/Fixed MMB ----- + + pub struct UnorderedFixedMmbHarness; + + impl SyncTestHarness for UnorderedFixedMmbHarness { + type Family = mmb::Family; + type Db = crate::qmdb::any::unordered::fixed::Db< + mmb::Family, + Context, + Digest, + Digest, + commonware_cryptography::Sha256, + TwoCap, + >; + + fn sync_target_root(db: &Self::Db) -> Digest { + db.root() + } + + fn config( + suffix: &str, + pooler: &impl BufferPooler, + ) -> crate::qmdb::any::FixedConfig { + crate::qmdb::any::test::fixed_db_config(suffix, pooler) + } + + fn create_ops( + n: usize, + ) -> Vec> + { + create_unordered_fixed_ops(n, 0) + } + + fn create_ops_seeded( + n: usize, + seed: u64, + ) -> Vec> + { + create_unordered_fixed_ops(n, seed) + } + + async fn init_db(mut ctx: Context) -> Self::Db { + let seed = ctx.next_u64(); + let cfg = crate::qmdb::any::test::fixed_db_config::(&seed.to_string(), &ctx); + Self::Db::init(ctx, cfg).await.unwrap() + } + + async fn init_db_with_config( + ctx: Context, + config: crate::qmdb::any::FixedConfig, + ) -> Self::Db { + Self::Db::init(ctx, config).await.unwrap() + } + + async fn apply_ops( + mut db: Self::Db, + ops: Vec>, + ) -> Self::Db { + use crate::qmdb::any::operation::Operation; + let mut batch = db.new_batch(); + for op in ops { + match op { + Operation::Update(data) => { + batch = batch.write(data.0, Some(data.1)); + } + Operation::Delete(key) => { + batch = batch.write(key, None); + } + Operation::CommitFloor(_, _) => {} + } + } + let merkleized = batch.merkleize(&db, None::).await.unwrap(); + db.apply_batch(merkleized).await.unwrap(); + db + } + } + + // ----- Unordered/Variable MMB ----- + + pub struct UnorderedVariableMmbHarness; + + impl SyncTestHarness for UnorderedVariableMmbHarness { + type Family = mmb::Family; + type Db = crate::qmdb::any::unordered::variable::Db< + mmb::Family, + Context, + Digest, + Vec, + commonware_cryptography::Sha256, + TwoCap, + >; + + fn sync_target_root(db: &Self::Db) -> Digest { + db.root() + } + + fn config( + suffix: &str, + pooler: &impl BufferPooler, + ) -> crate::qmdb::any::unordered::variable::test::VarConfig { + crate::qmdb::any::unordered::variable::test::create_test_config( + suffix.parse().unwrap_or(0), + pooler, + ) + } + + fn create_ops( + n: usize, + ) -> Vec>> + { + create_unordered_variable_ops(n, 0) + } + + fn create_ops_seeded( + n: usize, + seed: u64, + ) -> Vec>> + { + create_unordered_variable_ops(n, seed) + } + + async fn init_db(mut ctx: Context) -> Self::Db { + let seed = ctx.next_u64(); + let config = + crate::qmdb::any::unordered::variable::test::create_test_config(seed, &ctx); + Self::Db::init(ctx, config).await.unwrap() + } + + async fn init_db_with_config( + ctx: Context, + config: crate::qmdb::any::unordered::variable::test::VarConfig, + ) -> Self::Db { + Self::Db::init(ctx, config).await.unwrap() + } + + async fn apply_ops( + mut db: Self::Db, + ops: Vec< + crate::qmdb::any::unordered::variable::Operation>, + >, + ) -> Self::Db { + use crate::qmdb::any::operation::Operation; + let mut batch = db.new_batch(); + for op in ops { + match op { + Operation::Update(data) => { + batch = batch.write(data.0, Some(data.1)); + } + Operation::Delete(key) => { + batch = batch.write(key, None); + } + Operation::CommitFloor(_, _) => {} + } + } + let merkleized = batch.merkleize(&db, None::>).await.unwrap(); + db.apply_batch(merkleized).await.unwrap(); + db + } + } } // ===== Test Generation Macro ===== @@ -2266,6 +2721,17 @@ macro_rules! sync_tests_for_harness { fn test_sync_retries_bad_pinned_nodes() { super::test_sync_retries_bad_pinned_nodes::<$harness>(); } + } + }; +} + +/// Additional from_sync_result tests that require `FromSyncTestable`. +/// Only the MMR harnesses have `FromSyncTestable` impls. +macro_rules! from_sync_result_tests_for_harness { + ($harness:ty, $mod_name:ident) => { + mod $mod_name { + use super::harnesses; + use commonware_macros::test_traced; #[test_traced] fn test_sync_waits_for_boundary_retry_after_target_update() { @@ -2295,7 +2761,28 @@ macro_rules! sync_tests_for_harness { }; } +// MMR harnesses (all tests including from_sync_result) sync_tests_for_harness!(harnesses::OrderedFixedHarness, ordered_fixed); sync_tests_for_harness!(harnesses::OrderedVariableHarness, ordered_variable); sync_tests_for_harness!(harnesses::UnorderedFixedHarness, unordered_fixed); sync_tests_for_harness!(harnesses::UnorderedVariableHarness, unordered_variable); + +from_sync_result_tests_for_harness!(harnesses::OrderedFixedHarness, ordered_fixed_from_sync); +from_sync_result_tests_for_harness!( + harnesses::OrderedVariableHarness, + ordered_variable_from_sync +); +from_sync_result_tests_for_harness!(harnesses::UnorderedFixedHarness, unordered_fixed_from_sync); +from_sync_result_tests_for_harness!( + harnesses::UnorderedVariableHarness, + unordered_variable_from_sync +); + +// MMB harnesses (sync tests only, no from_sync_result) +sync_tests_for_harness!(harnesses::OrderedFixedMmbHarness, ordered_fixed_mmb); +sync_tests_for_harness!(harnesses::OrderedVariableMmbHarness, ordered_variable_mmb); +sync_tests_for_harness!(harnesses::UnorderedFixedMmbHarness, unordered_fixed_mmb); +sync_tests_for_harness!( + harnesses::UnorderedVariableMmbHarness, + unordered_variable_mmb +); diff --git a/storage/src/qmdb/any/unordered/fixed.rs b/storage/src/qmdb/any/unordered/fixed.rs index b73f586b48e..247593c2ec9 100644 --- a/storage/src/qmdb/any/unordered/fixed.rs +++ b/storage/src/qmdb/any/unordered/fixed.rs @@ -798,9 +798,9 @@ pub(crate) mod test { type TestMmr = Mmr; impl FromSyncTestable for AnyTest { - type Mmr = TestMmr; + type Merkle = TestMmr; - fn into_log_components(self) -> (Self::Mmr, Self::Journal) { + fn into_log_components(self) -> (Self::Merkle, Self::Journal) { (self.log.merkle, self.log.journal) } diff --git a/storage/src/qmdb/any/unordered/variable.rs b/storage/src/qmdb/any/unordered/variable.rs index 1af9eefa128..05ffd7d9ae7 100644 --- a/storage/src/qmdb/any/unordered/variable.rs +++ b/storage/src/qmdb/any/unordered/variable.rs @@ -720,9 +720,9 @@ pub(crate) mod test { type TestMmr = Mmr; impl FromSyncTestable for AnyTest { - type Mmr = TestMmr; + type Merkle = TestMmr; - fn into_log_components(self) -> (Self::Mmr, Self::Journal) { + fn into_log_components(self) -> (Self::Merkle, Self::Journal) { (self.log.merkle, self.log.journal) } diff --git a/storage/src/qmdb/current/db.rs b/storage/src/qmdb/current/db.rs index f33ea151c75..79415ec3759 100644 --- a/storage/src/qmdb/current/db.rs +++ b/storage/src/qmdb/current/db.rs @@ -102,7 +102,10 @@ where { /// Return the inactivity floor location. This is the location before which all operations are /// known to be inactive. Operations before this point can be safely pruned. - pub const fn inactivity_floor_loc(&self) -> Location { + /// + /// This is an implementation detail of the activity tracking; external callers should + /// prefer [`Self::sync_boundary`] when constructing a sync target or calling [`Self::prune`]. + pub(crate) const fn inactivity_floor_loc(&self) -> Location { self.any.inactivity_floor_loc() } @@ -282,6 +285,21 @@ where self.any.pinned_nodes_at(loc).await } + /// Returns the most recent location from which this database can safely be synced. + /// + /// Callers constructing a sync [`Target`](crate::qmdb::sync::Target) may use this value, or + /// any earlier retained location, as `range.start`. Values *above* this boundary are unsafe: + /// the receiver's grafted-pin derivation requires chunk-aligned, absorbed state at the start + /// of the range, and locations above this boundary place that derivation in the + /// delayed-merge-unstable region (relevant for MMB). + /// + /// For families without delayed merges this is the inactivity floor rounded down to the + /// nearest chunk boundary. For families with delayed merges (MMB) it is held back further, + /// until the youngest pruned chunk-pair's height-`gh+1` parent has been born in the ops tree. + pub fn sync_boundary(&self) -> Result, Error> { + self.settled_bitmap_prune_loc() + } + /// For the youngest of `pruned_chunks` chunks, return the `peak_birth_size` of its /// chunk-pair parent at height `gh+1`. Returns `None` for families without delayed merges /// (where `peak_birth_size` at height `gh` equals the chunk boundary). @@ -397,8 +415,9 @@ where /// Prunes historical operations prior to `prune_loc`. This does not affect the db's root or /// snapshot. /// - /// `prune_loc` controls ops-log pruning only. Bitmap pruning advances to the settled - /// portion of the inactivity floor, independent of `prune_loc`. + /// Pruning is clipped to the settled bitmap boundary (see [`Db::sync_boundary`]): the ops + /// log's lower bound is never advanced past where the grafting overlay has been pruned. The + /// bitmap and grafted tree advance to that same settled boundary regardless of `prune_loc`. /// /// # Errors /// @@ -460,7 +479,7 @@ where // a pruned log with stale metadata would lose peak digests permanently. self.sync_metadata().await?; - self.any.prune(prune_loc).await + self.any.prune(prune_loc.min(settled_bitmap_floor)).await } /// Rewind the database to `size` operations, where `size` is the location of the next append. diff --git a/storage/src/qmdb/current/mod.rs b/storage/src/qmdb/current/mod.rs index 415de5a69ab..8fc8dac1f37 100644 --- a/storage/src/qmdb/current/mod.rs +++ b/storage/src/qmdb/current/mod.rs @@ -1779,6 +1779,131 @@ pub mod tests { }); } + /// Verify that `Db::prune` never advances the ops journal past the settled bitmap + /// pruning boundary on a delayed-merge (MMB) family. The journal's lower bound must be + /// less than or equal to `pruning_boundary()`, and the test setup must force the lag to + /// be strictly active so the assertion is not vacuous. + #[test_traced] + fn test_current_mmb_prune_clips_journal_to_settled_boundary() { + let executor = deterministic::Runner::default(); + executor.start(|context| async move { + const COMMITS: u64 = 320; + + let ctx = context.with_label("db"); + let mut db: UnorderedVariableMmbDb = UnorderedVariableMmbDb::init( + ctx.clone(), + variable_config::("prune-clip-mmb", &ctx), + ) + .await + .unwrap(); + + let k = key(0); + for round in 0..COMMITS { + mmb_commit(&mut db, [(k, Some(val(70_000 + round)))]).await; + } + + db.prune(db.inactivity_floor_loc()).await.unwrap(); + + let boundary = db.sync_boundary().unwrap(); + let floor = db.inactivity_floor_loc(); + assert!( + boundary < floor, + "delayed-merge lag must be strictly active: boundary={boundary}, floor={floor}" + ); + assert!( + db.bounds().await.start <= boundary, + "ops journal was pruned past the settled bitmap boundary: \ + bounds.start={}, boundary={boundary}", + db.bounds().await.start + ); + + db.destroy().await.unwrap(); + }); + } + + /// Verify that on a non-delayed-merge (MMR) family `pruning_boundary()` lags the + /// inactivity floor only by chunk alignment (less than one chunk) — never by a + /// delayed-merge absorption window. Guards against an accidental regression that + /// would introduce a larger lag on families that don't need it. + #[test_traced] + fn test_current_mmr_prune_boundary_lag_is_only_chunk_alignment() { + let executor = deterministic::Runner::default(); + executor.start(|context| async move { + const COMMITS: u64 = 320; + const N: usize = 32; + + let ctx = context.with_label("db"); + let mut db: UnorderedVariableDb = UnorderedVariableDb::init( + ctx.clone(), + variable_config::("prune-clip-mmr", &ctx), + ) + .await + .unwrap(); + + for round in 0..COMMITS { + commit_writes_with_metadata( + &mut db, + [(key(0), Some(val(80_000 + round)))], + None, + ) + .await; + } + + db.prune(db.inactivity_floor_loc()).await.unwrap(); + + let boundary = db.sync_boundary().unwrap(); + let floor = db.inactivity_floor_loc(); + let chunk_bits = commonware_utils::bitmap::BitMap::::CHUNK_SIZE_BITS; + assert!( + boundary <= floor && *floor - *boundary < chunk_bits, + "MMR lag should be only chunk alignment: boundary={boundary}, floor={floor}, chunk_bits={chunk_bits}" + ); + assert!( + db.bounds().await.start <= boundary, + "ops journal bounds must be <= pruning_boundary: bounds.start={}, boundary={boundary}", + db.bounds().await.start + ); + + db.destroy().await.unwrap(); + }); + } + + /// Verify that `prune(loc)` with `loc < pruning_boundary()` prunes the ops journal only + /// as far as the caller requested. The clip must pick the smaller of the two — it must + /// not over-prune when the caller asked for less than the settled boundary. + #[test_traced] + fn test_current_prune_below_settled_boundary_is_honored() { + let executor = deterministic::Runner::default(); + executor.start(|context| async move { + const COMMITS: u64 = 100; + + let ctx = context.with_label("db"); + let mut db: UnorderedVariableDb = UnorderedVariableDb::init( + ctx.clone(), + variable_config::("prune-below-boundary", &ctx), + ) + .await + .unwrap(); + + for round in 0..COMMITS { + commit_writes_with_metadata(&mut db, [(key(0), Some(val(90_000 + round)))], None) + .await; + } + + assert!(*db.inactivity_floor_loc() > 1); + let small = Location::new(1); + db.prune(small).await.unwrap(); + + assert!( + db.bounds().await.start <= small, + "journal pruning exceeded the caller-supplied target: bounds.start={}, requested={small}", + db.bounds().await.start + ); + + db.destroy().await.unwrap(); + }); + } + /// Prune, then grow without pruning again so delayed MMB merges occur inside the /// already-pruned region. Verify proof + reopen correctness. #[test_traced] diff --git a/storage/src/qmdb/current/sync/mod.rs b/storage/src/qmdb/current/sync/mod.rs index d8cc851e495..9107d03a486 100644 --- a/storage/src/qmdb/current/sync/mod.rs +++ b/storage/src/qmdb/current/sync/mod.rs @@ -32,8 +32,9 @@ use crate::{ contiguous::{fixed, variable, Mutable}, }, merkle::{ - mmr::{self, Family, Location, StandardHasher}, - Family as _, + hasher::Standard as StandardHasher, + journaled::{self, Journaled}, + Graftable, Location, }, qmdb::{ self, @@ -68,8 +69,10 @@ use crate::{ }; use commonware_codec::{Codec, CodecShared, Read as CodecRead}; use commonware_cryptography::{DigestOf, Hasher}; -use commonware_utils::{bitmap::Prunable as BitMap, channel::oneshot, sync::AsyncMutex, Array}; -use std::{ops::Range, sync::Arc}; +use commonware_utils::{ + bitmap::Prunable as BitMap, channel::oneshot, range::NonEmptyRange, sync::AsyncMutex, Array, +}; +use std::sync::Arc; #[cfg(test)] pub(crate) mod tests; @@ -90,32 +93,33 @@ impl Config for super::Config { /// * Builds the grafted MMR from the bitmap and ops MMR. /// * Computes and caches the canonical root. #[allow(clippy::too_many_arguments)] -async fn build_db( +async fn build_db( context: E, - mmr_config: mmr::journaled::Config, + merkle_config: journaled::Config, log: J, translator: T, pinned_nodes: Option>, - range: Range, + range: NonEmptyRange>, apply_batch_size: usize, metadata_partition: String, thread_pool: Option, -) -> Result, qmdb::Error> +) -> Result, qmdb::Error> where + F: Graftable, E: Context, U: Update + Send + Sync + 'static, - I: IndexFactory, + I: IndexFactory>, H: Hasher, T: Translator, - J: Mutable> + Persistable, - Operation: Codec + Committable + CodecShared, + J: Mutable> + Persistable, + Operation: Codec + Committable + CodecShared, { // Build authenticated log. let hasher = StandardHasher::::new(); - let mmr = mmr::journaled::Mmr::init_sync( + let merkle = Journaled::::init_sync( context.with_label("mmr"), - mmr::journaled::SyncConfig { - config: mmr_config, + journaled::SyncConfig { + config: merkle_config, range: range.clone(), pinned_nodes, }, @@ -123,8 +127,8 @@ where ) .await?; let index = I::new(context.with_label("index"), translator); - let log = authenticated::Journal::::from_components( - mmr, + let log = authenticated::Journal::::from_components( + merkle, log, hasher, apply_batch_size as u64, @@ -137,20 +141,20 @@ where // If range.start is not chunk-aligned, the partial leading chunk is reconstructed by // init_from_log, which pads the gap between `pruned_chunks * CHUNK_SIZE_BITS` and the // journal's inactivity floor with inactive (false) bits. - let pruned_chunks = (*range.start / BitMap::::CHUNK_SIZE_BITS) as usize; + let pruned_chunks = (*range.start() / BitMap::::CHUNK_SIZE_BITS) as usize; let mut status = BitMap::::new_with_pruned_chunks(pruned_chunks) - .map_err(|_| qmdb::Error::::DataCorrupted("pruned chunks overflow"))?; + .map_err(|_| qmdb::Error::::DataCorrupted("pruned chunks overflow"))?; // Build any::Db with bitmap callback. // // init_from_log replays the operations, building the snapshot (index) and invoking // our callback for each operation to populate the bitmap. - let known_inactivity_floor = Location::new(status.len()); - let any: AnyDb = AnyDb::init_from_log( + let known_inactivity_floor = Location::::new(status.len()); + let any: AnyDb = AnyDb::init_from_log( index, log, Some(known_inactivity_floor), - |is_active: bool, old_loc: Option| { + |is_active: bool, old_loc: Option>| { status.push(is_active); if let Some(loc) = old_loc { status.set_bit(*loc, false); @@ -169,14 +173,21 @@ where // `nodes_to_pin(range.start)` returns all ops peaks, but only the first // `popcount(pruned_chunks)` are at or above the grafting height. The remaining // smaller peaks cover the partial trailing chunk and are not grafted pinned nodes. + // + // This relies on the pruning-boundary invariant: at `range.end`, every pruned chunk's + // height-`gh` subtree is absorbed, so `nodes_to_pin` at a chunk-aligned `range.start` + // returns the correct positions for both MMR and MMB. let grafted_pinned_nodes = { - let ops_pin_positions = mmr::Family::nodes_to_pin(range.start); + let ops_pin_positions: Vec<_> = F::nodes_to_pin(range.start()).collect(); let num_grafted_pins = (pruned_chunks as u64).count_ones() as usize; let mut pins = Vec::with_capacity(num_grafted_pins); - for pos in ops_pin_positions.take(num_grafted_pins) { - let digest = any.log.merkle.get_node(pos).await?.ok_or( - qmdb::Error::::DataCorrupted("missing ops pinned node"), - )?; + for pos in ops_pin_positions.into_iter().take(num_grafted_pins) { + let digest = any + .log + .merkle + .get_node(pos) + .await? + .ok_or(qmdb::Error::::DataCorrupted("missing ops pinned node"))?; pins.push(digest); } pins @@ -184,7 +195,7 @@ where // Build grafted MMR. let hasher = StandardHasher::::new(); - let grafted_tree = db::build_grafted_tree::( + let grafted_tree = db::build_grafted_tree::( &hasher, &status, &grafted_pinned_nodes, @@ -217,11 +228,9 @@ where ); // Initialize metadata store and construct the Db. - let (metadata, _, _) = db::init_metadata::>( - context.with_label("metadata"), - &metadata_partition, - ) - .await?; + let (metadata, _, _) = + db::init_metadata::>(context.with_label("metadata"), &metadata_partition) + .await?; let current_db = db::Db { any, @@ -245,8 +254,9 @@ macro_rules! impl_current_sync_database { $journal:ty, $config:ty, $key_bound:path, $value_bound:ident $(; $($where_extra:tt)+)?) => { - impl Database for $db + impl Database for $db where + F: Graftable, E: Context, K: $key_bound, V: $value_bound + 'static, @@ -254,8 +264,9 @@ macro_rules! impl_current_sync_database { T: Translator, $($($where_extra)+)? { + type Family = F; type Context = E; - type Op = $op; + type Op = $op; type Journal = $journal; type Hasher = H; type Config = $config; @@ -266,16 +277,16 @@ macro_rules! impl_current_sync_database { config: Self::Config, log: Self::Journal, pinned_nodes: Option>, - range: Range, + range: NonEmptyRange>, apply_batch_size: usize, - ) -> Result> { - let mmr_config = config.merkle_config.clone(); + ) -> Result> { + let merkle_config = config.merkle_config.clone(); let metadata_partition = config.grafted_metadata_partition.clone(); let thread_pool = config.merkle_config.thread_pool.clone(); let translator = config.translator.clone(); - build_db::<_, $update, _, H, _, T, N>( + build_db::, _, H, _, T, N>( context, - mmr_config, + merkle_config, log, translator, pinned_nodes, @@ -290,9 +301,9 @@ macro_rules! impl_current_sync_database { async fn has_local_target_state( context: Self::Context, config: &Self::Config, - target: &qmdb::sync::Target, + target: &qmdb::sync::Target, ) -> bool { - qmdb::any::sync::has_local_target_state::<_, H>( + qmdb::any::sync::has_local_target_state::( context, config.merkle_config.clone(), target, @@ -318,9 +329,9 @@ impl_current_sync_database!( impl_current_sync_database!( CurrentUnorderedVariableDb, UnorderedVariableOp, UnorderedVariableUpdate, variable::Journal, - VariableConfig as CodecRead>::Cfg>, + VariableConfig as CodecRead>::Cfg>, Key, VariableValue; - UnorderedVariableOp: CodecShared + UnorderedVariableOp: CodecShared ); impl_current_sync_database!( @@ -332,9 +343,9 @@ impl_current_sync_database!( impl_current_sync_database!( CurrentOrderedVariableDb, OrderedVariableOp, OrderedVariableUpdate, variable::Journal, - VariableConfig as CodecRead>::Cfg>, + VariableConfig as CodecRead>::Cfg>, Key, VariableValue; - OrderedVariableOp: CodecShared + OrderedVariableOp: CodecShared ); // --- Resolver implementations --- @@ -344,9 +355,10 @@ impl_current_sync_database!( macro_rules! impl_current_resolver { ($db:ident, $op:ident, $val_bound:ident, $key_bound:path $(; $($where_extra:tt)+)?) => { - impl crate::qmdb::sync::Resolver - for std::sync::Arc<$db> + impl crate::qmdb::sync::Resolver + for std::sync::Arc<$db> where + F: Graftable, E: Context, K: $key_bound, V: $val_bound + Send + Sync + 'static, @@ -355,18 +367,19 @@ macro_rules! impl_current_resolver { T::Key: Send + Sync, $($($where_extra)+)? { + type Family = F; type Digest = H::Digest; - type Op = $op; - type Error = qmdb::Error; + type Op = $op; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: std::num::NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, Self::Error> { + ) -> Result, Self::Error> { let (proof, operations) = self.any .historical_proof(op_count, start_loc, max_ops) .await?; @@ -384,13 +397,14 @@ macro_rules! impl_current_resolver { } } - impl crate::qmdb::sync::Resolver + impl crate::qmdb::sync::Resolver for std::sync::Arc< commonware_utils::sync::AsyncRwLock< - $db, + $db, >, > where + F: Graftable, E: Context, K: $key_bound, V: $val_bound + Send + Sync + 'static, @@ -399,18 +413,19 @@ macro_rules! impl_current_resolver { T::Key: Send + Sync, $($($where_extra)+)? { + type Family = F; type Digest = H::Digest; - type Op = $op; - type Error = qmdb::Error; + type Op = $op; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: std::num::NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, qmdb::Error> { + ) -> Result, qmdb::Error> { let db = self.read().await; let (proof, operations) = db.any .historical_proof(op_count, start_loc, max_ops) @@ -429,13 +444,14 @@ macro_rules! impl_current_resolver { } } - impl crate::qmdb::sync::Resolver + impl crate::qmdb::sync::Resolver for std::sync::Arc< commonware_utils::sync::AsyncRwLock< - Option<$db>, + Option<$db>, >, > where + F: Graftable, E: Context, K: $key_bound, V: $val_bound + Send + Sync + 'static, @@ -444,20 +460,21 @@ macro_rules! impl_current_resolver { T::Key: Send + Sync, $($($where_extra)+)? { + type Family = F; type Digest = H::Digest; - type Op = $op; - type Error = qmdb::Error; + type Op = $op; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: std::num::NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, qmdb::Error> { + ) -> Result, qmdb::Error> { let guard = self.read().await; - let db = guard.as_ref().ok_or(qmdb::Error::::KeyNotFound)?; + let db = guard.as_ref().ok_or(qmdb::Error::::KeyNotFound)?; let (proof, operations) = db.any .historical_proof(op_count, start_loc, max_ops) .await?; @@ -483,7 +500,7 @@ impl_current_resolver!(CurrentUnorderedFixedDb, UnorderedFixedOp, FixedValue, Ar // Unordered Variable impl_current_resolver!( CurrentUnorderedVariableDb, UnorderedVariableOp, VariableValue, Key; - UnorderedVariableOp: CodecShared, + UnorderedVariableOp: CodecShared, ); // Ordered Fixed @@ -492,5 +509,5 @@ impl_current_resolver!(CurrentOrderedFixedDb, OrderedFixedOp, FixedValue, Array) // Ordered Variable impl_current_resolver!( CurrentOrderedVariableDb, OrderedVariableOp, VariableValue, Key; - OrderedVariableOp: CodecShared, + OrderedVariableOp: CodecShared, ); diff --git a/storage/src/qmdb/current/sync/tests.rs b/storage/src/qmdb/current/sync/tests.rs index e19fb123bfb..ae32012d8dc 100644 --- a/storage/src/qmdb/current/sync/tests.rs +++ b/storage/src/qmdb/current/sync/tests.rs @@ -5,37 +5,272 @@ //! `any` harnesses is that `sync_target_root` returns the **ops root** (via //! [qmdb::sync::Database::root](crate::qmdb::sync::Database::root)), not the canonical root //! returned by `Db::root()`. - -use crate::{ - merkle::mmr, - qmdb::{ - any::sync::tests::{ConfigOf, SyncTestHarness}, - current::tests::{fixed_config, variable_config}, - sync::Database as SyncDatabase, - }, +//! +//! Harnesses are instantiated for **both** MMR and MMB merkle families across each (ordered, +//! unordered) x (fixed, variable) database variant, so the shared suite runs twice per +//! variant. +//! +//! In addition to the shared harness-based suite, this module contains focused tests for +//! `current`-specific sync behavior: overlay-state authentication (canonical-root check), +//! pruned MMB round-trip, and target-update regression coverage. + +use crate::qmdb::{ + any::sync::tests::{ConfigOf, SyncTestHarness}, + current::tests::{fixed_config, variable_config}, + sync::Database as SyncDatabase, }; -use commonware_cryptography::sha256::Digest; -use commonware_runtime::{deterministic::Context, BufferPooler}; +use commonware_cryptography::{sha256::Digest, Sha256}; +use commonware_macros::test_traced; +use commonware_runtime::{ + deterministic, deterministic::Context, BufferPooler, Metrics as _, Runner as _, +}; +use rand::RngCore as _; // ===== Harness Implementations ===== mod harnesses { use super::*; + use crate::merkle::{self, mmb, mmr}; + use commonware_math::algebra::Random; + use commonware_utils::test_rng_seeded; - // ----- Unordered/Fixed ----- + type OrderedFixedDb = crate::qmdb::current::ordered::fixed::Db< + F, + Context, + Digest, + Digest, + Sha256, + crate::translator::OneCap, + 32, + >; + type OrderedVariableDb = crate::qmdb::current::ordered::variable::Db< + F, + Context, + Digest, + Digest, + Sha256, + crate::translator::OneCap, + 32, + >; + type UnorderedFixedDb = crate::qmdb::current::unordered::fixed::Db< + F, + Context, + Digest, + Digest, + Sha256, + crate::translator::TwoCap, + 32, + >; + type UnorderedVariableDb = crate::qmdb::current::unordered::variable::Db< + F, + Context, + Digest, + Digest, + Sha256, + crate::translator::TwoCap, + 32, + >; + + fn create_unordered_fixed_ops( + n: usize, + seed: u64, + ) -> Vec> { + use crate::qmdb::any::operation::{update::Unordered as Update, Operation}; + + let mut rng = test_rng_seeded(seed); + let mut prev_key = Digest::random(&mut rng); + let mut ops = Vec::new(); + for i in 0..n { + let key = Digest::random(&mut rng); + if i % 10 == 0 && i > 0 { + ops.push(Operation::Delete(prev_key)); + } else { + let value = Digest::random(&mut rng); + ops.push(Operation::Update(Update(key, value))); + prev_key = key; + } + } + ops + } - pub struct UnorderedFixedHarness; + fn create_unordered_variable_ops( + n: usize, + seed: u64, + ) -> Vec> { + use crate::qmdb::any::operation::{update::Unordered as Update, Operation}; + + let mut rng = test_rng_seeded(seed); + let mut prev_key = Digest::random(&mut rng); + let mut ops = Vec::new(); + for i in 0..n { + let key = Digest::random(&mut rng); + if i % 10 == 0 && i > 0 { + ops.push(Operation::Delete(prev_key)); + } else { + let value = Digest::random(&mut rng); + ops.push(Operation::Update(Update(key, value))); + prev_key = key; + } + } + ops + } - impl SyncTestHarness for UnorderedFixedHarness { - type Db = crate::qmdb::current::unordered::fixed::Db< - mmr::Family, - Context, - Digest, - Digest, - commonware_cryptography::Sha256, - crate::translator::TwoCap, - 32, - >; + fn create_ordered_fixed_ops( + n: usize, + seed: u64, + ) -> Vec> { + use crate::qmdb::any::operation::{update::Ordered as Update, Operation}; + + let mut rng = test_rng_seeded(seed); + let mut ops = Vec::new(); + for i in 0..n { + if i % 10 == 0 && i > 0 { + let key = Digest::random(&mut rng); + ops.push(Operation::Delete(key)); + } else { + let key = Digest::random(&mut rng); + let value = Digest::random(&mut rng); + let next_key = Digest::random(&mut rng); + ops.push(Operation::Update(Update { + key, + value, + next_key, + })); + } + } + ops + } + + fn create_ordered_variable_ops( + n: usize, + seed: u64, + ) -> Vec> { + use crate::qmdb::any::operation::{update::Ordered as Update, Operation}; + + let mut rng = test_rng_seeded(seed); + let mut ops = Vec::new(); + for i in 0..n { + let key = Digest::random(&mut rng); + if i % 10 == 0 && i > 0 { + ops.push(Operation::Delete(key)); + } else { + let value = Digest::random(&mut rng); + let next_key = Digest::random(&mut rng); + ops.push(Operation::Update(Update { + key, + value, + next_key, + })); + } + } + ops + } + + async fn apply_unordered_fixed_ops( + mut db: UnorderedFixedDb, + ops: Vec>, + ) -> UnorderedFixedDb { + use crate::qmdb::any::operation::{update::Unordered as Update, Operation}; + + let merkleized = { + let mut batch = db.new_batch(); + for op in ops { + match op { + Operation::Update(Update(key, value)) => { + batch = batch.write(key, Some(value)); + } + Operation::Delete(key) => { + batch = batch.write(key, None); + } + Operation::CommitFloor(_, _) => {} + } + } + batch.merkleize(&db, None::).await.unwrap() + }; + db.apply_batch(merkleized).await.unwrap(); + db + } + + async fn apply_unordered_variable_ops( + mut db: UnorderedVariableDb, + ops: Vec>, + ) -> UnorderedVariableDb { + use crate::qmdb::any::operation::{update::Unordered as Update, Operation}; + + let merkleized = { + let mut batch = db.new_batch(); + for op in ops { + match op { + Operation::Update(Update(key, value)) => { + batch = batch.write(key, Some(value)); + } + Operation::Delete(key) => { + batch = batch.write(key, None); + } + Operation::CommitFloor(_, _) => {} + } + } + batch.merkleize(&db, None::).await.unwrap() + }; + db.apply_batch(merkleized).await.unwrap(); + db + } + + async fn apply_ordered_fixed_ops( + mut db: OrderedFixedDb, + ops: Vec>, + ) -> OrderedFixedDb { + use crate::qmdb::any::operation::{update::Ordered as Update, Operation}; + + let merkleized = { + let mut batch = db.new_batch(); + for op in ops { + match op { + Operation::Update(Update { key, value, .. }) => { + batch = batch.write(key, Some(value)); + } + Operation::Delete(key) => { + batch = batch.write(key, None); + } + Operation::CommitFloor(_, _) => {} + } + } + batch.merkleize(&db, None::).await.unwrap() + }; + db.apply_batch(merkleized).await.unwrap(); + db + } + + async fn apply_ordered_variable_ops( + mut db: OrderedVariableDb, + ops: Vec>, + ) -> OrderedVariableDb { + use crate::qmdb::any::operation::{update::Ordered as Update, Operation}; + + let merkleized = { + let mut batch = db.new_batch(); + for op in ops { + match op { + Operation::Update(Update { key, value, .. }) => { + batch = batch.write(key, Some(value)); + } + Operation::Delete(key) => { + batch = batch.write(key, None); + } + Operation::CommitFloor(_, _) => {} + } + } + batch.merkleize(&db, None::).await.unwrap() + }; + db.apply_batch(merkleized).await.unwrap(); + db + } + + pub struct UnorderedFixedMmrHarness; + + impl SyncTestHarness for UnorderedFixedMmrHarness { + type Family = mmr::Family; + type Db = UnorderedFixedDb; fn sync_target_root(db: &Self::Db) -> Digest { SyncDatabase::root(db) @@ -49,7 +284,7 @@ mod harnesses { n: usize, ) -> Vec> { - crate::qmdb::any::unordered::fixed::test::create_test_ops(n) + create_unordered_fixed_ops::(n, 0) } fn create_ops_seeded( @@ -57,7 +292,7 @@ mod harnesses { seed: u64, ) -> Vec> { - crate::qmdb::any::unordered::fixed::test::create_test_ops_seeded(n, seed) + create_unordered_fixed_ops::(n, seed) } async fn init_db(ctx: Context) -> Self::Db { @@ -70,42 +305,64 @@ mod harnesses { } async fn apply_ops( - mut db: Self::Db, + db: Self::Db, ops: Vec>, ) -> Self::Db { - use crate::qmdb::any::operation::{update::Unordered as Update, Operation}; - let mut batch = db.new_batch(); - for op in ops { - match op { - Operation::Update(Update(key, value)) => { - batch = batch.write(key, Some(value)); - } - Operation::Delete(key) => { - batch = batch.write(key, None); - } - Operation::CommitFloor(_, _) => {} - } - } - let merkleized = batch.merkleize(&db, None::).await.unwrap(); - db.apply_batch(merkleized).await.unwrap(); - db + apply_unordered_fixed_ops(db, ops).await } } - // ----- Unordered/Variable ----- + pub struct UnorderedFixedMmbHarness; - pub struct UnorderedVariableHarness; + impl SyncTestHarness for UnorderedFixedMmbHarness { + type Family = mmb::Family; + type Db = UnorderedFixedDb; - impl SyncTestHarness for UnorderedVariableHarness { - type Db = crate::qmdb::current::unordered::variable::Db< - mmr::Family, - Context, - Digest, - Digest, - commonware_cryptography::Sha256, - crate::translator::TwoCap, - 32, - >; + fn sync_target_root(db: &Self::Db) -> Digest { + SyncDatabase::root(db) + } + + fn config(suffix: &str, pooler: &impl BufferPooler) -> ConfigOf { + fixed_config::(suffix, pooler) + } + + fn create_ops( + n: usize, + ) -> Vec> + { + create_unordered_fixed_ops::(n, 0) + } + + fn create_ops_seeded( + n: usize, + seed: u64, + ) -> Vec> + { + create_unordered_fixed_ops::(n, seed) + } + + async fn init_db(ctx: Context) -> Self::Db { + let cfg = fixed_config::("default", &ctx); + Self::Db::init(ctx, cfg).await.unwrap() + } + + async fn init_db_with_config(ctx: Context, config: ConfigOf) -> Self::Db { + Self::Db::init(ctx, config).await.unwrap() + } + + async fn apply_ops( + db: Self::Db, + ops: Vec>, + ) -> Self::Db { + apply_unordered_fixed_ops(db, ops).await + } + } + + pub struct UnorderedVariableMmrHarness; + + impl SyncTestHarness for UnorderedVariableMmrHarness { + type Family = mmr::Family; + type Db = UnorderedVariableDb; fn sync_target_root(db: &Self::Db) -> Digest { SyncDatabase::root(db) @@ -119,7 +376,7 @@ mod harnesses { n: usize, ) -> Vec> { - create_unordered_variable_ops(n, 0) + create_unordered_variable_ops::(n, 0) } fn create_ops_seeded( @@ -127,7 +384,7 @@ mod harnesses { seed: u64, ) -> Vec> { - create_unordered_variable_ops(n, seed) + create_unordered_variable_ops::(n, seed) } async fn init_db(ctx: Context) -> Self::Db { @@ -140,42 +397,64 @@ mod harnesses { } async fn apply_ops( - mut db: Self::Db, + db: Self::Db, ops: Vec>, ) -> Self::Db { - use crate::qmdb::any::operation::{update::Unordered as Update, Operation}; - let mut batch = db.new_batch(); - for op in ops { - match op { - Operation::Update(Update(key, value)) => { - batch = batch.write(key, Some(value)); - } - Operation::Delete(key) => { - batch = batch.write(key, None); - } - Operation::CommitFloor(_, _) => {} - } - } - let merkleized = batch.merkleize(&db, None::).await.unwrap(); - db.apply_batch(merkleized).await.unwrap(); - db + apply_unordered_variable_ops(db, ops).await } } - // ----- Ordered/Fixed ----- + pub struct UnorderedVariableMmbHarness; - pub struct OrderedFixedHarness; + impl SyncTestHarness for UnorderedVariableMmbHarness { + type Family = mmb::Family; + type Db = UnorderedVariableDb; - impl SyncTestHarness for OrderedFixedHarness { - type Db = crate::qmdb::current::ordered::fixed::Db< - mmr::Family, - Context, - Digest, - Digest, - commonware_cryptography::Sha256, - crate::translator::OneCap, - 32, - >; + fn sync_target_root(db: &Self::Db) -> Digest { + SyncDatabase::root(db) + } + + fn config(suffix: &str, pooler: &impl BufferPooler) -> ConfigOf { + variable_config::(suffix, pooler) + } + + fn create_ops( + n: usize, + ) -> Vec> + { + create_unordered_variable_ops::(n, 0) + } + + fn create_ops_seeded( + n: usize, + seed: u64, + ) -> Vec> + { + create_unordered_variable_ops::(n, seed) + } + + async fn init_db(ctx: Context) -> Self::Db { + let cfg = variable_config::("default", &ctx); + Self::Db::init(ctx, cfg).await.unwrap() + } + + async fn init_db_with_config(ctx: Context, config: ConfigOf) -> Self::Db { + Self::Db::init(ctx, config).await.unwrap() + } + + async fn apply_ops( + db: Self::Db, + ops: Vec>, + ) -> Self::Db { + apply_unordered_variable_ops(db, ops).await + } + } + + pub struct OrderedFixedMmrHarness; + + impl SyncTestHarness for OrderedFixedMmrHarness { + type Family = mmr::Family; + type Db = OrderedFixedDb; fn sync_target_root(db: &Self::Db) -> Digest { SyncDatabase::root(db) @@ -188,14 +467,14 @@ mod harnesses { fn create_ops( n: usize, ) -> Vec> { - crate::qmdb::any::ordered::fixed::test::create_test_ops(n) + create_ordered_fixed_ops::(n, 0) } fn create_ops_seeded( n: usize, seed: u64, ) -> Vec> { - crate::qmdb::any::ordered::fixed::test::create_test_ops_seeded(n, seed) + create_ordered_fixed_ops::(n, seed) } async fn init_db(ctx: Context) -> Self::Db { @@ -208,42 +487,62 @@ mod harnesses { } async fn apply_ops( - mut db: Self::Db, + db: Self::Db, ops: Vec>, ) -> Self::Db { - use crate::qmdb::any::operation::{update::Ordered as Update, Operation}; - let mut batch = db.new_batch(); - for op in ops { - match op { - Operation::Update(Update { key, value, .. }) => { - batch = batch.write(key, Some(value)); - } - Operation::Delete(key) => { - batch = batch.write(key, None); - } - Operation::CommitFloor(_, _) => {} - } - } - let merkleized = batch.merkleize(&db, None::).await.unwrap(); - db.apply_batch(merkleized).await.unwrap(); - db + apply_ordered_fixed_ops(db, ops).await } } - // ----- Ordered/Variable ----- + pub struct OrderedFixedMmbHarness; - pub struct OrderedVariableHarness; + impl SyncTestHarness for OrderedFixedMmbHarness { + type Family = mmb::Family; + type Db = OrderedFixedDb; - impl SyncTestHarness for OrderedVariableHarness { - type Db = crate::qmdb::current::ordered::variable::Db< - mmr::Family, - Context, - Digest, - Digest, - commonware_cryptography::Sha256, - crate::translator::OneCap, - 32, - >; + fn sync_target_root(db: &Self::Db) -> Digest { + SyncDatabase::root(db) + } + + fn config(suffix: &str, pooler: &impl BufferPooler) -> ConfigOf { + fixed_config::(suffix, pooler) + } + + fn create_ops( + n: usize, + ) -> Vec> { + create_ordered_fixed_ops::(n, 0) + } + + fn create_ops_seeded( + n: usize, + seed: u64, + ) -> Vec> { + create_ordered_fixed_ops::(n, seed) + } + + async fn init_db(ctx: Context) -> Self::Db { + let cfg = fixed_config::("default", &ctx); + Self::Db::init(ctx, cfg).await.unwrap() + } + + async fn init_db_with_config(ctx: Context, config: ConfigOf) -> Self::Db { + Self::Db::init(ctx, config).await.unwrap() + } + + async fn apply_ops( + db: Self::Db, + ops: Vec>, + ) -> Self::Db { + apply_ordered_fixed_ops(db, ops).await + } + } + + pub struct OrderedVariableMmrHarness; + + impl SyncTestHarness for OrderedVariableMmrHarness { + type Family = mmr::Family; + type Db = OrderedVariableDb; fn sync_target_root(db: &Self::Db) -> Digest { SyncDatabase::root(db) @@ -257,7 +556,7 @@ mod harnesses { n: usize, ) -> Vec> { - create_ordered_variable_ops(n, 0) + create_ordered_variable_ops::(n, 0) } fn create_ops_seeded( @@ -265,7 +564,7 @@ mod harnesses { seed: u64, ) -> Vec> { - create_ordered_variable_ops(n, seed) + create_ordered_variable_ops::(n, seed) } async fn init_db(ctx: Context) -> Self::Db { @@ -278,82 +577,168 @@ mod harnesses { } async fn apply_ops( - mut db: Self::Db, + db: Self::Db, ops: Vec>, ) -> Self::Db { - use crate::qmdb::any::operation::{update::Ordered as Update, Operation}; - let mut batch = db.new_batch(); - for op in ops { - match op { - Operation::Update(Update { key, value, .. }) => { - batch = batch.write(key, Some(value)); - } - Operation::Delete(key) => { - batch = batch.write(key, None); - } - Operation::CommitFloor(_, _) => {} - } - } - let merkleized = batch.merkleize(&db, None::).await.unwrap(); - db.apply_batch(merkleized).await.unwrap(); - db + apply_ordered_variable_ops(db, ops).await } } -} -// ===== Helper functions for creating test operations ===== + pub struct OrderedVariableMmbHarness; -/// Create test operations for unordered variable databases with Digest values. -fn create_unordered_variable_ops( - n: usize, - seed: u64, -) -> Vec> { - use crate::qmdb::any::operation::{update::Unordered as Update, Operation}; - use commonware_math::algebra::Random; - use commonware_utils::test_rng_seeded; + impl SyncTestHarness for OrderedVariableMmbHarness { + type Family = mmb::Family; + type Db = OrderedVariableDb; + + fn sync_target_root(db: &Self::Db) -> Digest { + SyncDatabase::root(db) + } - let mut rng = test_rng_seeded(seed); - let mut prev_key = Digest::random(&mut rng); - let mut ops = Vec::new(); - for i in 0..n { - let key = Digest::random(&mut rng); - if i % 10 == 0 && i > 0 { - ops.push(Operation::Delete(prev_key)); - } else { - let value = Digest::random(&mut rng); - ops.push(Operation::Update(Update(key, value))); - prev_key = key; + fn config(suffix: &str, pooler: &impl BufferPooler) -> ConfigOf { + variable_config::(suffix, pooler) + } + + fn create_ops( + n: usize, + ) -> Vec> + { + create_ordered_variable_ops::(n, 0) + } + + fn create_ops_seeded( + n: usize, + seed: u64, + ) -> Vec> + { + create_ordered_variable_ops::(n, seed) + } + + async fn init_db(ctx: Context) -> Self::Db { + let cfg = variable_config::("default", &ctx); + Self::Db::init(ctx, cfg).await.unwrap() + } + + async fn init_db_with_config(ctx: Context, config: ConfigOf) -> Self::Db { + Self::Db::init(ctx, config).await.unwrap() + } + + async fn apply_ops( + db: Self::Db, + ops: Vec>, + ) -> Self::Db { + apply_ordered_variable_ops(db, ops).await } } - ops } -/// Create test operations for ordered variable databases with Digest values. -fn create_ordered_variable_ops( - n: usize, - seed: u64, -) -> Vec> { - use crate::qmdb::any::operation::{update::Ordered as Update, Operation}; - use commonware_math::algebra::Random; - use commonware_utils::test_rng_seeded; +/// Regression test: sync a pruned MMB-backed current DB and verify the synced DB has the +/// same canonical root, reopens cleanly, and returns the expected value. +/// +/// The target DB commits the same key 100 times, forcing the inactivity floor past a full +/// 256-bit chunk boundary. Without overlay-state in the sync protocol, the receiver +/// re-derives `pruned_chunks` from `range.start / chunk_bits` and builds a grafted tree +/// whose pinned nodes don't match the sender's. The canonical roots diverge. +#[test_traced("INFO")] +fn test_current_mmb_sync_with_pruned_full_chunk_reopens() { + let executor = deterministic::Runner::default(); + executor.start(|mut context: Context| async move { + type Db = crate::qmdb::current::unordered::variable::Db< + crate::merkle::mmb::Family, + Context, + Digest, + Digest, + Sha256, + crate::translator::TwoCap, + 32, + >; - let mut rng = test_rng_seeded(seed); - let mut ops = Vec::new(); - for i in 0..n { - let key = Digest::random(&mut rng); - if i % 10 == 0 && i > 0 { - ops.push(Operation::Delete(key)); - } else { - let value = Digest::random(&mut rng); - let next_key = Digest::random(&mut rng); - ops.push(Operation::Update(Update { - key, - value, - next_key, - })); + const COMMITS: u64 = 100; + + let target_suffix = context.next_u64().to_string(); + let target_context = context.with_label("target"); + let mut target_db: Db = Db::init( + target_context.clone(), + variable_config::(&target_suffix, &target_context), + ) + .await + .unwrap(); + + let key = Digest::from([7u8; 32]); + let mut expected = None; + for round in 0..COMMITS { + expected = Some(Digest::from([round as u8; 32])); + let merkleized = target_db + .new_batch() + .write(key, expected) + .merkleize(&target_db, None) + .await + .unwrap(); + target_db.apply_batch(merkleized).await.unwrap(); + target_db.commit().await.unwrap(); } - } - ops + + assert!( + *target_db.inactivity_floor_loc() >= 256, + "expected inactivity floor past chunk 0" + ); + + target_db + .prune(target_db.inactivity_floor_loc()) + .await + .unwrap(); + + let sync_root = SyncDatabase::root(&target_db); + let verification_root = target_db.root(); + let lower_bound = target_db.inactivity_floor_loc(); + let upper_bound = target_db.bounds().await.end; + + let client_suffix = context.next_u64().to_string(); + let client_config = variable_config::(&client_suffix, &context); + let target_db = std::sync::Arc::new(target_db); + // Supply the trusted canonical root so `build_db`'s authentication check actually + // runs: this is the success-path coverage for the overlay-state authentication + // anchor. A bad-root rejection path test belongs with the focused sync tests. + let synced_db: Db = crate::qmdb::sync::sync(crate::qmdb::sync::engine::Config { + context: context.with_label("client"), + db_config: client_config.clone(), + fetch_batch_size: commonware_utils::NZU64!(64), + target: crate::qmdb::sync::Target { + root: sync_root, + range: commonware_utils::non_empty_range!(lower_bound, upper_bound), + }, + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 4, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }) + .await + .unwrap(); + + assert_eq!(SyncDatabase::root(&synced_db), sync_root); + assert_eq!(synced_db.root(), verification_root); + assert_eq!(synced_db.inactivity_floor_loc(), lower_bound); + assert_eq!(synced_db.get(&key).await.unwrap(), expected); + + drop(synced_db); + + let reopened: Db = Db::init(context.with_label("reopened"), client_config) + .await + .unwrap(); + assert_eq!(SyncDatabase::root(&reopened), sync_root); + assert_eq!(reopened.root(), verification_root); + assert_eq!(reopened.inactivity_floor_loc(), lower_bound); + assert_eq!(reopened.get(&key).await.unwrap(), expected); + + reopened.destroy().await.unwrap(); + std::sync::Arc::try_unwrap(target_db) + .unwrap_or_else(|_| panic!("failed to unwrap Arc")) + .destroy() + .await + .unwrap(); + }); } // ===== Test Generation Macro ===== @@ -491,7 +876,17 @@ macro_rules! current_sync_tests_for_harness { }; } -current_sync_tests_for_harness!(harnesses::UnorderedFixedHarness, unordered_fixed); -current_sync_tests_for_harness!(harnesses::UnorderedVariableHarness, unordered_variable); -current_sync_tests_for_harness!(harnesses::OrderedFixedHarness, ordered_fixed); -current_sync_tests_for_harness!(harnesses::OrderedVariableHarness, ordered_variable); +current_sync_tests_for_harness!(harnesses::UnorderedFixedMmrHarness, unordered_fixed_mmr); +current_sync_tests_for_harness!(harnesses::UnorderedFixedMmbHarness, unordered_fixed_mmb); +current_sync_tests_for_harness!( + harnesses::UnorderedVariableMmrHarness, + unordered_variable_mmr +); +current_sync_tests_for_harness!( + harnesses::UnorderedVariableMmbHarness, + unordered_variable_mmb +); +current_sync_tests_for_harness!(harnesses::OrderedFixedMmrHarness, ordered_fixed_mmr); +current_sync_tests_for_harness!(harnesses::OrderedFixedMmbHarness, ordered_fixed_mmb); +current_sync_tests_for_harness!(harnesses::OrderedVariableMmrHarness, ordered_variable_mmr); +current_sync_tests_for_harness!(harnesses::OrderedVariableMmbHarness, ordered_variable_mmb); diff --git a/storage/src/qmdb/immutable/mod.rs b/storage/src/qmdb/immutable/mod.rs index 4cb236f15da..1525abba1bd 100644 --- a/storage/src/qmdb/immutable/mod.rs +++ b/storage/src/qmdb/immutable/mod.rs @@ -194,6 +194,15 @@ where Location::new(bounds.start)..Location::new(bounds.end) } + /// Return the most recent location from which this database can safely be synced. + /// + /// Immutable databases have no inactivity concept; this returns the oldest retained + /// operation. Callers constructing a sync [`Target`](crate::qmdb::sync::Target) may use this + /// value or any later location as `range.start`. + pub async fn sync_boundary(&self) -> Location { + self.bounds().await.start + } + /// Get the value of `key` in the db, or None if it has no value or its corresponding operation /// has been pruned. pub async fn get(&self, key: &K) -> Result, Error> { diff --git a/storage/src/qmdb/immutable/sync.rs b/storage/src/qmdb/immutable/sync.rs index 39fa5870e8c..6a55c95e671 100644 --- a/storage/src/qmdb/immutable/sync.rs +++ b/storage/src/qmdb/immutable/sync.rs @@ -7,7 +7,7 @@ use crate::{ }, merkle::{ journaled::{self, Journaled}, - mmr, Location, + Family, Location, }, qmdb::{ any::ValueEncoding, @@ -22,23 +22,25 @@ use crate::{ }; use commonware_codec::EncodeShared; use commonware_cryptography::Hasher; -use std::ops::Range; +use commonware_utils::range::NonEmptyRange; type StandardHasher = crate::merkle::hasher::Standard; -impl sync::Database for immutable::Immutable +impl sync::Database for immutable::Immutable where + F: Family, E: Context, K: Key, V: ValueEncoding, C: Mutable> + Persistable - + sync::Journal>, + + sync::Journal>, C::Item: EncodeShared, C::Config: Clone + Send, H: Hasher, T: Translator, { + type Family = F; type Op = Operation; type Journal = C; type Hasher = H; @@ -67,9 +69,9 @@ where db_config: Self::Config, log: Self::Journal, pinned_nodes: Option>, - range: Range, + range: NonEmptyRange>, apply_batch_size: usize, - ) -> Result> { + ) -> Result> { let hasher = StandardHasher::new(); // Initialize Merkle structure for sync @@ -92,23 +94,18 @@ where ) .await?; - let mut snapshot: Index = + let mut snapshot: Index> = Index::new(context.with_label("snapshot"), db_config.translator.clone()); let last_commit_loc = { // Get the start of the log. let reader = journal.journal.reader().await; let bounds = reader.bounds(); - let start_loc = mmr::Location::new(bounds.start); + let start_loc = Location::::new(bounds.start); // Build snapshot from the log - build_snapshot_from_log::( - start_loc, - &reader, - &mut snapshot, - |_, _| {}, - ) - .await?; + build_snapshot_from_log::(start_loc, &reader, &mut snapshot, |_, _| {}) + .await?; Location::new(bounds.end.checked_sub(1).expect("commit should exist")) }; diff --git a/storage/src/qmdb/keyless/mod.rs b/storage/src/qmdb/keyless/mod.rs index 37895864572..bacd70df3cb 100644 --- a/storage/src/qmdb/keyless/mod.rs +++ b/storage/src/qmdb/keyless/mod.rs @@ -149,6 +149,15 @@ where Location::new(bounds.start)..Location::new(bounds.end) } + /// Return the most recent location from which this database can safely be synced. + /// + /// Keyless databases have no inactivity concept; this returns the oldest retained + /// operation. Callers constructing a sync [`Target`](crate::qmdb::sync::Target) may use this + /// value or any later location as `range.start`. + pub async fn sync_boundary(&self) -> Location { + self.bounds().await.start + } + /// Get the metadata associated with the last commit. pub async fn get_metadata(&self) -> Result, Error> { let op = self diff --git a/storage/src/qmdb/keyless/sync.rs b/storage/src/qmdb/keyless/sync.rs index 8548be2e1b2..78d7bae8f9c 100644 --- a/storage/src/qmdb/keyless/sync.rs +++ b/storage/src/qmdb/keyless/sync.rs @@ -5,8 +5,9 @@ use crate::{ Error as JournalError, }, merkle::{ + hasher::Standard as StandardHasher, journaled::{self, Journaled}, - mmr::{self, Location, StandardHasher}, + Family, Location, }, qmdb::{ self, @@ -19,19 +20,21 @@ use crate::{ }; use commonware_codec::EncodeShared; use commonware_cryptography::Hasher; -use std::ops::Range; +use commonware_utils::range::NonEmptyRange; -impl sync::Database for Keyless +impl sync::Database for Keyless where + F: Family, E: Context, V: ValueEncoding + Codec, C: Mutable> + Persistable - + sync::Journal>, + + sync::Journal>, C::Config: Clone + Send, H: Hasher, Operation: EncodeShared, { + type Family = F; type Op = Operation; type Journal = C; type Hasher = H; @@ -59,9 +62,9 @@ where config: Self::Config, log: Self::Journal, pinned_nodes: Option>, - range: Range, + range: NonEmptyRange>, apply_batch_size: usize, - ) -> Result> { + ) -> Result> { let hasher = StandardHasher::::new(); let merkle = Journaled::init_sync( @@ -75,7 +78,7 @@ where ) .await?; - let journal = authenticated::Journal::::from_components( + let journal = authenticated::Journal::::from_components( merkle, log, hasher, @@ -226,7 +229,7 @@ mod tests { fn test_sync_resolver_fails() { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { - let resolver = FailResolver::::new(); + let resolver = FailResolver::::new(); let db_config = create_sync_config(&context.next_u64().to_string(), &context); let config = Config { context: context.with_label("client"), diff --git a/storage/src/qmdb/sync/database.rs b/storage/src/qmdb/sync/database.rs index 2a6b2b4dae6..43100e52abe 100644 --- a/storage/src/qmdb/sync/database.rs +++ b/storage/src/qmdb/sync/database.rs @@ -1,6 +1,11 @@ -use crate::{mmr::Location, qmdb::sync::Journal, translator::Translator}; +use crate::{ + merkle::{Family, Location}, + qmdb::sync::Journal, + translator::Translator, +}; use commonware_cryptography::Digest; -use std::{future::Future, ops::Range}; +use commonware_utils::range::NonEmptyRange; +use std::future::Future; pub trait Config { type JournalConfig; @@ -30,10 +35,12 @@ impl Config for crate::qmdb::keyless::Config { self.log.clone() } } + pub trait Database: Sized + Send { + type Family: Family; type Op: Send; - type Journal: Journal; - type Config: Config::Config>; + type Journal: Journal; + type Config: Config>::Config>; type Digest: Digest; type Context: commonware_runtime::Storage + commonware_runtime::Clock @@ -46,9 +53,9 @@ pub trait Database: Sized + Send { config: Self::Config, journal: Self::Journal, pinned_nodes: Option>, - range: Range, + range: NonEmptyRange>, apply_batch_size: usize, - ) -> impl Future>> + Send; + ) -> impl Future>> + Send; /// Returns whether persisted local state already matches the requested sync target. /// @@ -69,7 +76,7 @@ pub trait Database: Sized + Send { fn has_local_target_state( _context: Self::Context, _config: &Self::Config, - _target: &crate::qmdb::sync::Target, + _target: &crate::qmdb::sync::Target, ) -> impl Future + Send { async { false } } diff --git a/storage/src/qmdb/sync/engine.rs b/storage/src/qmdb/sync/engine.rs index 915cff0638a..105f9625983 100644 --- a/storage/src/qmdb/sync/engine.rs +++ b/storage/src/qmdb/sync/engine.rs @@ -1,6 +1,6 @@ //! Core sync engine components that are shared across sync clients. use crate::{ - merkle::mmr::{Location, StandardHasher}, + merkle::{hasher::Standard as StandardHasher, Family, Location}, qmdb::{ self, sync::{ @@ -9,7 +9,7 @@ use crate::{ requests::{Id as RequestId, Requests}, resolver::{FetchResult, Resolver}, target::validate_update, - Database, Error as SyncError, Journal, Target, + Database, DbResolver, Error as SyncError, Journal, Target, }, }, }; @@ -36,7 +36,8 @@ use std::{ }; /// Type alias for sync engine errors -type Error = qmdb::sync::Error<::Error, ::Digest>; +type Error = + qmdb::sync::Error<::Family, ::Error, ::Digest>; /// Whether sync should continue or complete #[derive(Debug)] @@ -49,11 +50,11 @@ pub(crate) enum NextStep { /// Events that can occur during synchronization #[derive(Debug)] -enum Event { +enum Event { /// A target update was received - TargetUpdate(Target), + TargetUpdate(Target), /// A batch of operations was received - BatchReceived(IndexedFetchResult), + BatchReceived(IndexedFetchResult), /// The target update channel was closed UpdateChannelClosed, /// A finish signal was received @@ -64,20 +65,20 @@ enum Event { /// Result from a fetch operation with its request ID and starting location. #[derive(Debug)] -pub(super) struct IndexedFetchResult { +pub(super) struct IndexedFetchResult { /// Unique ID assigned when the request was scheduled. pub id: RequestId, /// The result of the fetch operation. - pub result: Result, E>, + pub result: Result, E>, } /// Wait for the next synchronization event. /// Returns `None` when there are no outstanding requests and no channels to wait on. -async fn wait_for_event( - update_rx: &mut Option>>, +async fn wait_for_event( + update_rx: &mut Option>>, finish_rx: &mut Option>, - outstanding_requests: &mut Requests, -) -> Option> { + outstanding_requests: &mut Requests, +) -> Option> { if outstanding_requests.len() == 0 && update_rx.is_none() && finish_rx.is_none() { return None; } @@ -113,7 +114,7 @@ async fn wait_for_event( pub struct Config where DB: Database, - R: Resolver, + R: DbResolver, DB::Op: Encode, { /// Runtime context for creating database components @@ -121,7 +122,7 @@ where /// Network resolver for fetching operations and proofs pub resolver: R, /// Sync target (root digest and operation bounds) - pub target: Target, + pub target: Target, /// Maximum number of outstanding requests for operation batches pub max_outstanding_requests: usize, /// Maximum operations to fetch per batch @@ -131,7 +132,7 @@ where /// Database-specific configuration pub db_config: DB::Config, /// Channel for receiving sync target updates - pub update_rx: Option>>, + pub update_rx: Option>>, /// Channel that requests sync completion once the current target is reached. /// /// When `None`, sync completes as soon as the target is reached. @@ -142,7 +143,7 @@ where /// When `reached_target_tx` is `Some(...)`, this receiver must be actively /// drained by the observer. The engine awaits send capacity on this channel before /// proceeding, so backpressure can pause progress at target. - pub reached_target_tx: Option>>, + pub reached_target_tx: Option>>, /// Maximum number of previous roots to retain for verifying in-flight /// requests after target updates. Set to 0 to disable (all retained /// requests will be re-fetched). @@ -152,18 +153,18 @@ where pub(crate) struct Engine where DB: Database, - R: Resolver, + R: DbResolver, DB::Op: Encode, { /// Tracks outstanding fetch requests and their futures - outstanding_requests: Requests, + outstanding_requests: Requests, /// Operations that have been fetched but not yet applied to the log. /// /// # Invariant /// /// The vectors in the map are non-empty. - fetched_operations: BTreeMap>, + fetched_operations: BTreeMap, Vec>, /// Pinned MMR nodes extracted from proofs, used for database construction pinned_nodes: Option>, @@ -177,17 +178,17 @@ where /// the MMR is append-only and validate_update rejects unchanged roots. /// When a retained request completes, proof.leaves identifies which /// historical root to verify against. - retained_roots: HashMap, + retained_roots: HashMap, DB::Digest>, /// Tree sizes of retained roots in insertion order (oldest first), /// used for FIFO eviction when retained_roots exceeds capacity. - retained_roots_order: VecDeque, + retained_roots_order: VecDeque>, /// Maximum number of historical roots to retain max_retained_roots: usize, /// The current sync target (root digest and operation bounds) - target: Target, + target: Target, /// Maximum number of parallel outstanding requests max_outstanding_requests: usize, @@ -214,7 +215,7 @@ where config: DB::Config, /// Optional receiver for target updates during sync - update_rx: Option>>, + update_rx: Option>>, /// Channel that requests sync completion once the current target is reached. /// @@ -227,7 +228,7 @@ where /// When `reached_target_tx` is `Some(...)`, this receiver must be actively /// drained by the observer. The engine awaits send capacity on this channel before /// proceeding, so backpressure can pause progress at target. - reached_target_tx: Option>>, + reached_target_tx: Option>>, /// Whether explicit finish has been requested. finish_requested: bool, @@ -240,7 +241,7 @@ where impl Engine where DB: Database, - R: Resolver, + R: DbResolver, DB::Op: Encode, { pub(crate) fn journal(&self) -> &DB::Journal { @@ -251,7 +252,7 @@ where impl Engine where DB: Database, - R: Resolver, + R: DbResolver, DB::Op: Encode, { /// Create a new sync engine with the given configuration @@ -277,10 +278,10 @@ where }; // Create journal and verifier using the database's factory methods - let journal = ::new( + let journal = >::new( config.context.with_label("journal"), config.db_config.journal_config(), - config.target.range.clone().into(), + config.target.range.clone(), ) .await?; @@ -349,7 +350,7 @@ where for _ in 0..num_requests { // Convert fetched operations to operation counts for shared gap detection - let operation_counts: BTreeMap = self + let operation_counts: BTreeMap, u64> = self .fetched_operations .iter() .map(|(&start_loc, operations)| (start_loc, operations.len() as u64)) @@ -399,7 +400,7 @@ where /// `retained_roots`) so the fetched operations can still be used. pub async fn reset_for_target_update( mut self, - new_target: Target, + new_target: Target, ) -> Result> { self.journal.resize(new_target.range.start()).await?; // Remove requests at or before the new start. The request at start @@ -483,7 +484,11 @@ where } /// Store a batch of fetched operations. If the input list is empty, this is a no-op. - pub(crate) fn store_operations(&mut self, start_loc: Location, operations: Vec) { + pub(crate) fn store_operations( + &mut self, + start_loc: Location, + operations: Vec, + ) { if operations.is_empty() { return; } @@ -597,7 +602,7 @@ where /// to a matching historical root from `retained_roots` if available. fn handle_fetch_result( &mut self, - fetch_result: IndexedFetchResult, + fetch_result: IndexedFetchResult, ) -> Result<(), Error> { // Discard results for stale requests (removed by a target update). // Using the request ID prevents a stale future from consuming the @@ -696,7 +701,7 @@ where /// Handle a sync event and return the next engine state. async fn handle_event( mut self, - event: Event, + event: Event, ) -> Result, Error> { match event { Event::TargetUpdate(new_target) => { @@ -760,7 +765,7 @@ where self.config, self.journal, self.pinned_nodes, - self.target.range.clone().into(), + self.target.range.clone(), self.apply_batch_size, ) .await?; @@ -807,16 +812,15 @@ where #[cfg(test)] mod tests { use super::*; - use crate::merkle::mmr::Proof; + use crate::{ + merkle::mmr::{Family as MmrFamily, Proof}, + qmdb::sync::requests::FetchFuture, + }; use commonware_cryptography::sha256; use commonware_utils::channel::oneshot; - use std::{future::Future, pin::Pin}; /// Create a no-op fetch result future for testing request tracking. - fn dummy_future( - id: RequestId, - ) -> Pin> + Send + Sync>> - { + fn dummy_future(id: RequestId) -> FetchFuture { Box::pin(async move { IndexedFetchResult { id, @@ -834,7 +838,7 @@ mod tests { } /// Helper to add a request at a given location. - fn add(requests: &mut Requests, loc: u64) -> RequestId { + fn add(requests: &mut Requests, loc: u64) -> RequestId { let id = requests.next_id(); requests.insert( id, @@ -848,7 +852,7 @@ mod tests { #[test] fn test_add_and_remove() { - let mut requests: Requests = Requests::new(); + let mut requests: Requests = Requests::new(); assert_eq!(requests.len(), 0); let id = add(&mut requests, 10); @@ -862,7 +866,7 @@ mod tests { #[test] fn test_remove_before() { - let mut requests: Requests = Requests::new(); + let mut requests: Requests = Requests::new(); add(&mut requests, 5); add(&mut requests, 10); @@ -880,7 +884,7 @@ mod tests { #[test] fn test_remove_before_all() { - let mut requests: Requests = Requests::new(); + let mut requests: Requests = Requests::new(); add(&mut requests, 5); add(&mut requests, 10); @@ -892,14 +896,14 @@ mod tests { #[test] fn test_remove_before_empty() { - let mut requests: Requests = Requests::new(); + let mut requests: Requests = Requests::new(); requests.remove_before(Location::new(10)); assert_eq!(requests.len(), 0); } #[test] fn test_remove_before_none() { - let mut requests: Requests = Requests::new(); + let mut requests: Requests = Requests::new(); add(&mut requests, 10); add(&mut requests, 20); @@ -913,7 +917,7 @@ mod tests { #[test] fn test_superseded_request() { - let mut requests: Requests = Requests::new(); + let mut requests: Requests = Requests::new(); // Old request at location 10 let old_id = add(&mut requests, 10); @@ -934,7 +938,7 @@ mod tests { #[test] fn test_stale_id_after_remove_before() { - let mut requests: Requests = Requests::new(); + let mut requests: Requests = Requests::new(); let old_id = add(&mut requests, 5); add(&mut requests, 15); diff --git a/storage/src/qmdb/sync/error.rs b/storage/src/qmdb/sync/error.rs index 823c7d4cfa7..c3a4a7f61d8 100644 --- a/storage/src/qmdb/sync/error.rs +++ b/storage/src/qmdb/sync/error.rs @@ -1,18 +1,21 @@ //! Shared sync error types that can be used across different database implementations. -use crate::{mmr::Location, qmdb::sync::Target}; +use crate::{ + merkle::{Family, Location}, + qmdb::sync::Target, +}; use commonware_cryptography::Digest; #[derive(Debug, thiserror::Error)] -pub enum EngineError { +pub enum EngineError { /// Hash mismatch after sync #[error("root digest mismatch - expected {expected:?}, got {actual:?}")] RootMismatch { expected: D, actual: D }, /// Invalid target parameters #[error("invalid bounds: lower bound {lower_bound_pos} > upper bound {upper_bound_pos}")] InvalidTarget { - lower_bound_pos: Location, - upper_bound_pos: Location, + lower_bound_pos: Location, + upper_bound_pos: Location, }, /// Invalid client state #[error("invalid client state")] @@ -22,7 +25,10 @@ pub enum EngineError { SyncTargetRootUnchanged, /// Sync target moved backward #[error("sync target moved backward: {old:?} -> {new:?}")] - SyncTargetMovedBackward { old: Target, new: Target }, + SyncTargetMovedBackward { + old: Target, + new: Target, + }, /// Sync already completed #[error("sync already completed")] AlreadyComplete, @@ -39,14 +45,15 @@ pub enum EngineError { /// Errors that can occur during database synchronization. #[derive(Debug, thiserror::Error)] -pub enum Error +pub enum Error where + F: Family, U: std::error::Error + Send + 'static, D: Digest, { /// Database error #[error("database error: {0}")] - Database(crate::qmdb::Error), + Database(crate::qmdb::Error), /// Resolver error #[error("resolver error: {0:?}")] @@ -54,14 +61,15 @@ where /// Engine error #[error("engine error: {0}")] - Engine(EngineError), + Engine(EngineError), } -impl From for Error +impl From for Error where + F: Family, U: std::error::Error + Send + 'static, D: Digest, - T: Into>, + T: Into>, { fn from(err: T) -> Self { Self::Database(err.into()) diff --git a/storage/src/qmdb/sync/gaps.rs b/storage/src/qmdb/sync/gaps.rs index 6bd9ee6d82e..d2babaa65e0 100644 --- a/storage/src/qmdb/sync/gaps.rs +++ b/storage/src/qmdb/sync/gaps.rs @@ -1,6 +1,6 @@ //! Gap detection algorithm for sync operations. -use crate::merkle::mmr::Location; +use crate::merkle::{Family, Location}; use core::{num::NonZeroU64, ops::Range}; use std::collections::BTreeMap; @@ -23,18 +23,18 @@ use std::collections::BTreeMap; /// - All start locations in `fetched_operations` are in `range` /// - All start locations in `outstanding_requests` are in `range` /// - All operation counts in `fetched_operations` are > 0 -pub fn find_next<'a>( - range: Range, - fetched_operations: &BTreeMap, // start_loc -> operation_count - outstanding_requests: impl IntoIterator, +pub fn find_next<'a, F: Family>( + range: Range>, + fetched_operations: &BTreeMap, u64>, // start_loc -> operation_count + outstanding_requests: impl IntoIterator>, fetch_batch_size: NonZeroU64, -) -> Option> { +) -> Option>> { if range.is_empty() { return None; } // Track the next uncovered location (exclusive end of covered range) - let mut next_uncovered: Location = range.start; + let mut next_uncovered: Location = range.start; // Create iterators for both data structures (already sorted) let mut fetched_ops_iter = fetched_operations @@ -254,12 +254,13 @@ mod tests { expected: Some(0..1), })] fn test_find_next(#[case] test_case: FindNextTestCase) { - let fetched_ops: BTreeMap = test_case + use crate::merkle::mmr::Family as MmrFamily; + let fetched_ops: BTreeMap, u64> = test_case .fetched_ops .into_iter() .map(|(k, v)| (Location::new(k), v)) .collect(); - let outstanding_requests: Vec = test_case + let outstanding_requests: Vec> = test_case .requested_ops .into_iter() .map(Location::new) diff --git a/storage/src/qmdb/sync/journal.rs b/storage/src/qmdb/sync/journal.rs index 0c760dd79a6..669bd9358df 100644 --- a/storage/src/qmdb/sync/journal.rs +++ b/storage/src/qmdb/sync/journal.rs @@ -1,8 +1,12 @@ -use crate::{journal::contiguous::Contiguous, mmr::Location}; -use std::{future::Future, ops::Range}; +use crate::{ + journal::contiguous::Contiguous, + merkle::{Family, Location}, +}; +use commonware_utils::range::NonEmptyRange; +use std::future::Future; /// Journal of operations used by a [super::Database] -pub trait Journal: Sized + Send { +pub trait Journal: Sized + Send { /// The context of the journal type Context; @@ -13,10 +17,7 @@ pub trait Journal: Sized + Send { type Op: Send; /// The error type returned by the journal - type Error: std::error::Error - + Send - + 'static - + Into>; + type Error: std::error::Error + Send + 'static + Into>; /// Create/open a journal for syncing the given range. /// @@ -27,14 +28,17 @@ pub trait Journal: Sized + Send { fn new( context: Self::Context, config: Self::Config, - range: Range, - ) -> impl Future>; + range: NonEmptyRange>, + ) -> impl Future> + Send; /// Discard all operations before the given location. /// /// If current `size() <= start`, initialize as empty at the given location. /// Otherwise prune data before the given location. - fn resize(&mut self, start: Location) -> impl Future> + Send; + fn resize( + &mut self, + start: Location, + ) -> impl Future> + Send; /// Persist the journal. fn sync(&mut self) -> impl Future> + Send; @@ -46,8 +50,9 @@ pub trait Journal: Sized + Send { fn append(&mut self, op: Self::Op) -> impl Future> + Send; } -impl Journal for crate::journal::contiguous::variable::Journal +impl Journal for crate::journal::contiguous::variable::Journal where + F: Family, E: crate::Context, V: commonware_codec::CodecShared, { @@ -59,13 +64,13 @@ where async fn new( context: Self::Context, config: Self::Config, - range: Range, + range: NonEmptyRange>, ) -> Result { - Self::init_sync(context, config.clone(), *range.start..*range.end).await + Self::init_sync(context, config.clone(), *range.start()..*range.end()).await } - async fn resize(&mut self, start: Location) -> Result<(), Self::Error> { - if Contiguous::size(self).await <= start { + async fn resize(&mut self, start: Location) -> Result<(), Self::Error> { + if Contiguous::size(self).await <= *start { self.clear_to_size(*start).await } else { self.prune(*start).await.map(|_| ()) @@ -85,8 +90,9 @@ where } } -impl Journal for crate::journal::contiguous::fixed::Journal +impl Journal for crate::journal::contiguous::fixed::Journal where + F: Family, E: crate::Context, A: commonware_codec::CodecFixedShared, { @@ -98,29 +104,31 @@ where async fn new( context: Self::Context, config: Self::Config, - range: Range, + range: NonEmptyRange>, ) -> Result { - assert!(!range.is_empty(), "range must not be empty"); - let journal = Self::init(context, config).await?; let size = Contiguous::size(&journal).await; - if size > *range.end { + // Fresh journal already aligned with the sync start - nothing to do. + if size == 0 && *range.start() == 0 { + return Ok(journal); + } + + if size > *range.end() { return Err(crate::journal::Error::ItemOutOfRange(size)); } - if size <= *range.start { - if *range.start != 0 { - journal.clear_to_size(*range.start).await?; - } + + if size <= *range.start() { + journal.clear_to_size(*range.start()).await?; } else { - journal.prune(*range.start).await?; + journal.prune(*range.start()).await?; } Ok(journal) } - async fn resize(&mut self, start: Location) -> Result<(), Self::Error> { - if Contiguous::size(self).await <= start { + async fn resize(&mut self, start: Location) -> Result<(), Self::Error> { + if Contiguous::size(self).await <= *start { self.clear_to_size(*start).await } else { self.prune(*start).await.map(|_| ()) diff --git a/storage/src/qmdb/sync/mod.rs b/storage/src/qmdb/sync/mod.rs index e39d04029c2..ea427c38682 100644 --- a/storage/src/qmdb/sync/mod.rs +++ b/storage/src/qmdb/sync/mod.rs @@ -25,12 +25,29 @@ pub use target::Target; mod requests; +/// A [`Resolver`] whose associated types match a specific [`Database`]. +/// +/// Blanket-impled for any matching `Resolver`, so callers never implement this directly. +pub trait DbResolver: + Resolver +{ +} + +impl DbResolver for R +where + DB: Database, + R: Resolver, +{ +} + /// Create/open a database and sync it to a target state -pub async fn sync(config: Config) -> Result> +pub async fn sync( + config: Config, +) -> Result> where DB: Database, DB::Op: Encode, - R: resolver::Resolver, + R: DbResolver, { Engine::new(config).await?.sync().await } diff --git a/storage/src/qmdb/sync/requests.rs b/storage/src/qmdb/sync/requests.rs index c28bd97455a..d7098560bbc 100644 --- a/storage/src/qmdb/sync/requests.rs +++ b/storage/src/qmdb/sync/requests.rs @@ -5,7 +5,10 @@ //! same location after a target update, and lets the engine reject replies that //! do not match the requested historical view. -use crate::{mmr::Location, qmdb::sync::engine::IndexedFetchResult}; +use crate::{ + merkle::{Family, Location}, + qmdb::sync::engine::IndexedFetchResult, +}; use commonware_cryptography::Digest; use commonware_utils::channel::oneshot; use futures::stream::FuturesUnordered; @@ -15,43 +18,46 @@ use std::{ pin::Pin, }; +/// Boxed future that resolves to an [`IndexedFetchResult`]. +pub(super) type FetchFuture = + Pin> + Send>>; + /// Unique identifier for a fetch request. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(super) struct Id(u64); /// Immutable details about a tracked request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct RequestInfo { +pub(super) struct RequestInfo { /// The location of the first requested operation. - pub start_loc: Location, + pub start_loc: Location, /// The database size the request asked the resolver to prove against. - pub target_size: Location, + pub target_size: Location, } /// Mutable request state kept while the request is still tracked. -struct TrackedRequest { - info: RequestInfo, +struct TrackedRequest { + info: RequestInfo, _cancel_tx: oneshot::Sender<()>, } /// Manages outstanding fetch requests. -pub(super) struct Requests { +pub(super) struct Requests { /// Futures that will resolve to fetch results. - #[allow(clippy::type_complexity)] - futures: FuturesUnordered> + Send>>>, + futures: FuturesUnordered>, /// Counter for assigning unique request IDs. next_id: u64, /// Active requests keyed by ID. Removing an entry drops the cancel sender, /// causing the resolver's `cancel_rx.await` to return `Err`. - tracked: HashMap, + tracked: HashMap>, /// Reverse index from location to request ID, for gap detection. - by_location: BTreeMap, + by_location: BTreeMap, Id>, } -impl Requests { +impl Requests { pub fn new() -> Self { Self { futures: FuturesUnordered::new(), @@ -75,10 +81,10 @@ impl Requests { pub fn insert( &mut self, id: Id, - start_loc: Location, - target_size: Location, + start_loc: Location, + target_size: Location, cancel_tx: oneshot::Sender<()>, - future: Pin> + Send>>, + future: FetchFuture, ) { if let Some(old_id) = self.by_location.insert(start_loc, id) { self.tracked.remove(&old_id); @@ -97,7 +103,7 @@ impl Requests { } /// Complete a request by ID. Returns its metadata if it was tracked. - pub fn remove(&mut self, id: Id) -> Option { + pub fn remove(&mut self, id: Id) -> Option> { if let Some(TrackedRequest { info, _cancel_tx: _, @@ -116,7 +122,7 @@ impl Requests { /// Remove all requests at locations before `loc`. Dropped cancel senders /// signal resolvers to abort. - pub fn remove_before(&mut self, loc: Location) { + pub fn remove_before(&mut self, loc: Location) { let keep = self.by_location.split_off(&loc); for id in self.by_location.values() { self.tracked.remove(id); @@ -125,21 +131,17 @@ impl Requests { } /// Iterate over outstanding request locations in ascending order. - pub fn locations(&self) -> impl Iterator { + pub fn locations(&self) -> impl Iterator> { self.by_location.keys() } /// Check if a location has an outstanding request. - pub fn contains(&self, loc: &Location) -> bool { + pub fn contains(&self, loc: &Location) -> bool { self.by_location.contains_key(loc) } /// Get a mutable reference to the futures stream. - #[allow(clippy::type_complexity)] - pub fn futures_mut( - &mut self, - ) -> &mut FuturesUnordered> + Send>>> - { + pub fn futures_mut(&mut self) -> &mut FuturesUnordered> { &mut self.futures } @@ -149,7 +151,7 @@ impl Requests { } } -impl Default for Requests { +impl Default for Requests { fn default() -> Self { Self::new() } diff --git a/storage/src/qmdb/sync/resolver.rs b/storage/src/qmdb/sync/resolver.rs index 9b503aaa6b7..c8e9302217f 100644 --- a/storage/src/qmdb/sync/resolver.rs +++ b/storage/src/qmdb/sync/resolver.rs @@ -1,5 +1,5 @@ use crate::{ - merkle::mmr::{self, Location, Proof}, + merkle::{Family, Location, Proof}, qmdb::{ self, any::{ @@ -30,10 +30,10 @@ use commonware_cryptography::{Digest, Hasher}; use commonware_utils::{channel::oneshot, sync::AsyncRwLock, Array}; use std::{future::Future, num::NonZeroU64, sync::Arc}; -/// Result from a fetch operation -pub struct FetchResult { +/// Result from a fetch operation. +pub struct FetchResult { /// The proof for the operations - pub proof: Proof, + pub proof: Proof, /// The operations that were fetched pub operations: Vec, /// Channel to report success/failure back to resolver @@ -42,7 +42,7 @@ pub struct FetchResult { pub pinned_nodes: Option>, } -impl std::fmt::Debug for FetchResult { +impl std::fmt::Debug for FetchResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("FetchResult") .field("proof", &self.proof) @@ -53,8 +53,11 @@ impl std::fmt::Debug for FetchResult { } } -/// Trait for network communication with the sync server +/// Trait for network communication with the sync server. pub trait Resolver: Send + Sync + Clone + 'static { + /// The merkle family backing the resolver's proofs + type Family: Family; + /// The digest type used in proofs returned by the resolver type Digest: Digest; @@ -75,19 +78,21 @@ pub trait Resolver: Send + Sync + Clone + 'static { #[allow(clippy::type_complexity)] fn get_operations<'a>( &'a self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, cancel_rx: oneshot::Receiver<()>, - ) -> impl Future, Self::Error>> + Send + 'a; + ) -> impl Future, Self::Error>> + + Send + + 'a; } macro_rules! impl_resolver { ($db:ident, $op:ident, $val_bound:ident) => { - impl Resolver - for Arc<$db> + impl Resolver for Arc<$db> where + F: Family, E: Context, K: Array, V: $val_bound + Send + Sync + 'static, @@ -95,18 +100,19 @@ macro_rules! impl_resolver { T: Translator + Send + Sync + 'static, T::Key: Send + Sync, { + type Family = F; type Digest = H::Digest; - type Op = $op; - type Error = qmdb::Error; + type Op = $op; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, Self::Error> { + ) -> Result, Self::Error> { let (proof, operations) = self.historical_proof(op_count, start_loc, max_ops).await?; let pinned_nodes = if include_pinned_nodes { @@ -123,9 +129,9 @@ macro_rules! impl_resolver { } } - impl Resolver - for Arc>> + impl Resolver for Arc>> where + F: Family, E: Context, K: Array, V: $val_bound + Send + Sync + 'static, @@ -133,18 +139,19 @@ macro_rules! impl_resolver { T: Translator + Send + Sync + 'static, T::Key: Send + Sync, { + type Family = F; type Digest = H::Digest; - type Op = $op; - type Error = qmdb::Error; + type Op = $op; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, qmdb::Error> { + ) -> Result, Self::Error> { let db = self.read().await; let (proof, operations) = db.historical_proof(op_count, start_loc, max_ops).await?; let pinned_nodes = if include_pinned_nodes { @@ -161,9 +168,9 @@ macro_rules! impl_resolver { } } - impl Resolver - for Arc>>> + impl Resolver for Arc>>> where + F: Family, E: Context, K: Array, V: $val_bound + Send + Sync + 'static, @@ -171,18 +178,19 @@ macro_rules! impl_resolver { T: Translator + Send + Sync + 'static, T::Key: Send + Sync, { + type Family = F; type Digest = H::Digest; - type Op = $op; - type Error = qmdb::Error; + type Op = $op; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, qmdb::Error> { + ) -> Result, Self::Error> { let guard = self.read().await; let db = guard.as_ref().ok_or(qmdb::Error::KeyNotFound)?; let (proof, operations) = db.historical_proof(op_count, start_loc, max_ops).await?; @@ -218,8 +226,9 @@ impl_resolver!(OrderedVariableDb, OrderedVariableOperation, VariableValue); // so we use a separate macro. macro_rules! impl_resolver_immutable { ($db:ident, $op:ident, $val_bound:ident, $key_bound:path) => { - impl Resolver for Arc<$db> + impl Resolver for Arc<$db> where + F: Family, E: Context, K: $key_bound, V: $val_bound + Send + Sync + 'static, @@ -227,18 +236,19 @@ macro_rules! impl_resolver_immutable { T: Translator + Send + Sync + 'static, T::Key: Send + Sync, { + type Family = F; type Digest = H::Digest; type Op = $op; - type Error = qmdb::Error; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, Self::Error> { + ) -> Result, Self::Error> { let (proof, operations) = self.historical_proof(op_count, start_loc, max_ops).await?; let pinned_nodes = if include_pinned_nodes { @@ -255,8 +265,9 @@ macro_rules! impl_resolver_immutable { } } - impl Resolver for Arc>> + impl Resolver for Arc>> where + F: Family, E: Context, K: $key_bound, V: $val_bound + Send + Sync + 'static, @@ -264,18 +275,19 @@ macro_rules! impl_resolver_immutable { T: Translator + Send + Sync + 'static, T::Key: Send + Sync, { + type Family = F; type Digest = H::Digest; type Op = $op; - type Error = qmdb::Error; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, qmdb::Error> { + ) -> Result, Self::Error> { let db = self.read().await; let (proof, operations) = db.historical_proof(op_count, start_loc, max_ops).await?; let pinned_nodes = if include_pinned_nodes { @@ -292,8 +304,9 @@ macro_rules! impl_resolver_immutable { } } - impl Resolver for Arc>>> + impl Resolver for Arc>>> where + F: Family, E: Context, K: $key_bound, V: $val_bound + Send + Sync + 'static, @@ -301,18 +314,19 @@ macro_rules! impl_resolver_immutable { T: Translator + Send + Sync + 'static, T::Key: Send + Sync, { + type Family = F; type Digest = H::Digest; type Op = $op; - type Error = qmdb::Error; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, qmdb::Error> { + ) -> Result, Self::Error> { let guard = self.read().await; let db = guard.as_ref().ok_or(qmdb::Error::KeyNotFound)?; let (proof, operations) = db.historical_proof(op_count, start_loc, max_ops).await?; @@ -341,24 +355,26 @@ impl_resolver_immutable!(ImmutableVariableDb, ImmutableVariableOp, VariableValue // Keyless types have no key or translator, so they need their own macro. macro_rules! impl_resolver_keyless { ($db:ident, $op:ident, $val_bound:ident) => { - impl Resolver for Arc<$db> + impl Resolver for Arc<$db> where + F: Family, E: Context, V: $val_bound + Send + Sync + 'static, H: Hasher, { + type Family = F; type Digest = H::Digest; type Op = $op; - type Error = qmdb::Error; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, Self::Error> { + ) -> Result, Self::Error> { let (proof, operations) = self.historical_proof(op_count, start_loc, max_ops).await?; let pinned_nodes = if include_pinned_nodes { @@ -375,24 +391,26 @@ macro_rules! impl_resolver_keyless { } } - impl Resolver for Arc>> + impl Resolver for Arc>> where + F: Family, E: Context, V: $val_bound + Send + Sync + 'static, H: Hasher, { + type Family = F; type Digest = H::Digest; type Op = $op; - type Error = qmdb::Error; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, qmdb::Error> { + ) -> Result, Self::Error> { let db = self.read().await; let (proof, operations) = db.historical_proof(op_count, start_loc, max_ops).await?; let pinned_nodes = if include_pinned_nodes { @@ -409,24 +427,26 @@ macro_rules! impl_resolver_keyless { } } - impl Resolver for Arc>>> + impl Resolver for Arc>>> where + F: Family, E: Context, V: $val_bound + Send + Sync + 'static, H: Hasher, { + type Family = F; type Digest = H::Digest; type Op = $op; - type Error = qmdb::Error; + type Error = qmdb::Error; async fn get_operations( &self, - op_count: Location, - start_loc: Location, + op_count: Location, + start_loc: Location, max_ops: NonZeroU64, include_pinned_nodes: bool, _cancel_rx: oneshot::Receiver<()>, - ) -> Result, qmdb::Error> { + ) -> Result, Self::Error> { let guard = self.read().await; let db = guard.as_ref().ok_or(qmdb::Error::KeyNotFound)?; let (proof, operations) = db.historical_proof(op_count, start_loc, max_ops).await?; @@ -459,33 +479,34 @@ pub(crate) mod tests { /// A resolver that always fails. #[derive(Clone)] - pub struct FailResolver { - _phantom: PhantomData<(Op, D)>, + pub struct FailResolver { + _phantom: PhantomData<(F, Op, D)>, } - impl Resolver for FailResolver + impl Resolver for FailResolver where + F: Family, D: Digest, Op: Send + Sync + Clone + 'static, { + type Family = F; type Digest = D; type Op = Op; - type Error = qmdb::Error; + type Error = qmdb::Error; async fn get_operations( &self, - _op_count: Location, - _start_loc: Location, + _op_count: Location, + _start_loc: Location, _max_ops: NonZeroU64, _include_pinned_nodes: bool, _cancel: oneshot::Receiver<()>, - ) -> Result, qmdb::Error> - { + ) -> Result, qmdb::Error> { Err(qmdb::Error::KeyNotFound) // Arbitrary dummy error } } - impl FailResolver { + impl FailResolver { pub fn new() -> Self { Self { _phantom: PhantomData, diff --git a/storage/src/qmdb/sync/target.rs b/storage/src/qmdb/sync/target.rs index 1c2708c869d..0a6e0901a4e 100644 --- a/storage/src/qmdb/sync/target.rs +++ b/storage/src/qmdb/sync/target.rs @@ -1,9 +1,5 @@ -#[cfg(feature = "arbitrary")] -use crate::merkle::mmr::Family; -#[cfg(feature = "arbitrary")] -use crate::merkle::Family as _; use crate::{ - merkle::mmr::Location, + merkle::{Family, Location}, qmdb::sync::{self, error::EngineError}, }; use commonware_codec::{EncodeSize, Error as CodecError, Read, ReadExt as _, Write}; @@ -11,34 +7,54 @@ use commonware_cryptography::Digest; use commonware_runtime::{Buf, BufMut}; use commonware_utils::range::NonEmptyRange; -/// Target state to sync to -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Target { - /// The root digest we're syncing to +/// Target state to sync to. +/// +/// `PartialEq`, `Eq`, and `Clone` are implemented manually to avoid requiring `F` to implement +/// them. +#[derive(Debug)] +pub struct Target { + /// The ops root the sync engine verifies streaming batches against. pub root: D, /// Range of operations to sync - pub range: NonEmptyRange, + pub range: NonEmptyRange>, +} + +impl Clone for Target { + fn clone(&self) -> Self { + Self { + root: self.root, + range: self.range.clone(), + } + } } -impl Write for Target { +impl PartialEq for Target { + fn eq(&self, other: &Self) -> bool { + self.root == other.root && self.range == other.range + } +} + +impl Eq for Target {} + +impl Write for Target { fn write(&self, buf: &mut impl BufMut) { self.root.write(buf); self.range.write(buf); } } -impl EncodeSize for Target { +impl EncodeSize for Target { fn encode_size(&self) -> usize { self.root.encode_size() + self.range.encode_size() } } -impl Read for Target { +impl Read for Target { type Cfg = (); fn read_cfg(buf: &mut impl Buf, _: &()) -> Result { let root = D::read(buf)?; - let range = NonEmptyRange::::read(buf)?; + let range = NonEmptyRange::>::read(buf)?; if !range.start().is_valid() || !range.end().is_valid() { return Err(CodecError::Invalid( "storage::qmdb::sync::Target", @@ -50,13 +66,13 @@ impl Read for Target { } #[cfg(feature = "arbitrary")] -impl arbitrary::Arbitrary<'_> for Target +impl arbitrary::Arbitrary<'_> for Target where D: for<'a> arbitrary::Arbitrary<'a>, { fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result { let root = u.arbitrary()?; - let max_loc = Family::MAX_LEAVES; + let max_loc = F::MAX_LEAVES; let lower = u.int_in_range(0..=*max_loc - 1)?; let upper = u.int_in_range(lower + 1..=*max_loc)?; Ok(Self { @@ -67,11 +83,12 @@ where } /// Validate a target update against the current target -pub fn validate_update( - old_target: &Target, - new_target: &Target, -) -> Result<(), sync::Error> +pub fn validate_update( + old_target: &Target, + new_target: &Target, +) -> Result<(), sync::Error> where + F: Family, U: std::error::Error + Send + 'static, D: Digest, { @@ -82,10 +99,9 @@ where })); } - // Start must not decrease; end must strictly increase. Same end - // implies same tree size implies same root (the MMR is append-only), - // so retaining the old root under the old tree size in - // `retained_roots` requires a distinct end. + // Start must not decrease; end must strictly increase. Same end implies same tree size implies + // same root (the Merkle structure is append-only), so retaining the old root under the old tree + // size in `retained_roots` requires a distinct end. if new_target.range.start() < old_target.range.start() || new_target.range.end() <= old_target.range.end() { @@ -103,14 +119,19 @@ where } #[cfg(test)] +// Only `MmrFamily` is exercised here: `Target`'s codec and `validate_update` logic are +// family-agnostic (the family only influences `Location::is_valid` via `F::MAX_LEAVES` and +// the `arbitrary` range picker), so an MMB variant would duplicate coverage without catching +// anything new. mod tests { use super::*; + use crate::merkle::mmr::Family as MmrFamily; use commonware_cryptography::sha256; use commonware_utils::non_empty_range; use rstest::rstest; use std::io::Cursor; - fn target(root: sha256::Digest, start: u64, end: u64) -> Target { + fn target(root: sha256::Digest, start: u64, end: u64) -> Target { Target { root, range: non_empty_range!(Location::new(start), Location::new(end)), @@ -143,12 +164,12 @@ mod tests { // Manually encode root + two Locations to bypass the Range write panic let mut buffer = Vec::new(); sha256::Digest::from([42; 32]).write(&mut buffer); - Location::new(100).write(&mut buffer); // start - Location::new(50).write(&mut buffer); // end (< start = invalid) + Location::::new(100).write(&mut buffer); // start + Location::::new(50).write(&mut buffer); // end (< start = invalid) let mut cursor = Cursor::new(buffer); assert!(matches!( - Target::::read(&mut cursor), + Target::::read(&mut cursor), Err(CodecError::Invalid("Range", "start must be <= end")) )); @@ -156,16 +177,16 @@ mod tests { let root = sha256::Digest::from([42; 32]); let mut buffer = Vec::new(); root.write(&mut buffer); - (Location::new(100)..Location::new(100)).write(&mut buffer); + (Location::::new(100)..Location::::new(100)).write(&mut buffer); let mut cursor = Cursor::new(buffer); assert!(matches!( - Target::::read(&mut cursor), + Target::::read(&mut cursor), Err(CodecError::Invalid("NonEmptyRange", "start must be < end")) )); } - type TestError = sync::Error; + type TestError = sync::Error; #[rstest] #[case::valid_update( @@ -200,8 +221,8 @@ mod tests { Err(TestError::Engine(EngineError::SyncTargetRootUnchanged)) )] fn test_validate_update( - #[case] old_target: Target, - #[case] new_target: Target, + #[case] old_target: Target, + #[case] new_target: Target, #[case] expected: Result<(), TestError>, ) { let result = validate_update(&old_target, &new_target); @@ -246,7 +267,7 @@ mod tests { use commonware_codec::conformance::CodecConformance; commonware_conformance::conformance_tests! { - CodecConformance>, + CodecConformance>, } } } From ae0040f07e63ee79b687270a6f80c8813ccfb17b Mon Sep 17 00:00:00 2001 From: Roberto Bayardo Date: Sun, 19 Apr 2026 09:29:58 -0700 Subject: [PATCH 2/8] keyless/immutable test generalization --- storage/src/qmdb/current/db.rs | 13 +- storage/src/qmdb/current/mod.rs | 13 +- storage/src/qmdb/current/sync/tests.rs | 13 +- storage/src/qmdb/immutable/sync.rs | 1014 --------------------- storage/src/qmdb/immutable/sync/mod.rs | 129 +++ storage/src/qmdb/immutable/sync/tests.rs | 1016 +++++++++++++++++++++ storage/src/qmdb/keyless/fixed.rs | 68 ++ storage/src/qmdb/keyless/sync.rs | 1029 ---------------------- storage/src/qmdb/keyless/sync/mod.rs | 116 +++ storage/src/qmdb/keyless/sync/tests.rs | 1018 +++++++++++++++++++++ storage/src/qmdb/sync/mod.rs | 2 +- 11 files changed, 2365 insertions(+), 2066 deletions(-) delete mode 100644 storage/src/qmdb/immutable/sync.rs create mode 100644 storage/src/qmdb/immutable/sync/mod.rs create mode 100644 storage/src/qmdb/immutable/sync/tests.rs delete mode 100644 storage/src/qmdb/keyless/sync.rs create mode 100644 storage/src/qmdb/keyless/sync/mod.rs create mode 100644 storage/src/qmdb/keyless/sync/tests.rs diff --git a/storage/src/qmdb/current/db.rs b/storage/src/qmdb/current/db.rs index 79415ec3759..a5f0d42d7a3 100644 --- a/storage/src/qmdb/current/db.rs +++ b/storage/src/qmdb/current/db.rs @@ -292,10 +292,6 @@ where /// the receiver's grafted-pin derivation requires chunk-aligned, absorbed state at the start /// of the range, and locations above this boundary place that derivation in the /// delayed-merge-unstable region (relevant for MMB). - /// - /// For families without delayed merges this is the inactivity floor rounded down to the - /// nearest chunk boundary. For families with delayed merges (MMB) it is held back further, - /// until the youngest pruned chunk-pair's height-`gh+1` parent has been born in the ops tree. pub fn sync_boundary(&self) -> Result, Error> { self.settled_bitmap_prune_loc() } @@ -415,14 +411,15 @@ where /// Prunes historical operations prior to `prune_loc`. This does not affect the db's root or /// snapshot. /// - /// Pruning is clipped to the settled bitmap boundary (see [`Db::sync_boundary`]): the ops - /// log's lower bound is never advanced past where the grafting overlay has been pruned. The - /// bitmap and grafted tree advance to that same settled boundary regardless of `prune_loc`. + /// Pruning is clipped to the settled bitmap boundary (see [`Db::sync_boundary`]): the ops log's + /// lower bound is never advanced past where the grafting overlay has been pruned. The bitmap + /// and grafted tree advance to that same settled boundary regardless of `prune_loc`. /// /// # Errors /// /// - Returns [Error::PruneBeyondMinRequired] if `prune_loc` > inactivity floor. - /// - Returns [`crate::merkle::Error::LocationOverflow`] if `prune_loc` > [crate::merkle::Family::MAX_LEAVES]. + /// - Returns [`crate::merkle::Error::LocationOverflow`] if `prune_loc` > + /// [crate::merkle::Family::MAX_LEAVES]. pub async fn prune(&mut self, prune_loc: Location) -> Result<(), Error> { let inactivity_floor = self.inactivity_floor_loc(); if prune_loc > inactivity_floor { diff --git a/storage/src/qmdb/current/mod.rs b/storage/src/qmdb/current/mod.rs index 8fc8dac1f37..5f6dc58bc06 100644 --- a/storage/src/qmdb/current/mod.rs +++ b/storage/src/qmdb/current/mod.rs @@ -1821,10 +1821,10 @@ pub mod tests { }); } - /// Verify that on a non-delayed-merge (MMR) family `pruning_boundary()` lags the - /// inactivity floor only by chunk alignment (less than one chunk) — never by a - /// delayed-merge absorption window. Guards against an accidental regression that - /// would introduce a larger lag on families that don't need it. + /// Verify that on a non-delayed-merge (MMR) family `pruning_boundary()` lags the inactivity + /// floor only by chunk alignment (less than one chunk) — never by a delayed-merge absorption + /// window. Guards against an accidental regression that would introduce a larger lag on + /// families that don't need it. #[test_traced] fn test_current_mmr_prune_boundary_lag_is_only_chunk_alignment() { let executor = deterministic::Runner::default(); @@ -1868,9 +1868,8 @@ pub mod tests { }); } - /// Verify that `prune(loc)` with `loc < pruning_boundary()` prunes the ops journal only - /// as far as the caller requested. The clip must pick the smaller of the two — it must - /// not over-prune when the caller asked for less than the settled boundary. + /// Verify that `prune(loc)` with `loc < pruning_boundary()` prunes the ops journal only as far + /// as the caller requested. #[test_traced] fn test_current_prune_below_settled_boundary_is_honored() { let executor = deterministic::Runner::default(); diff --git a/storage/src/qmdb/current/sync/tests.rs b/storage/src/qmdb/current/sync/tests.rs index ae32012d8dc..b6fcb662d06 100644 --- a/storage/src/qmdb/current/sync/tests.rs +++ b/storage/src/qmdb/current/sync/tests.rs @@ -1,18 +1,17 @@ //! Tests for [crate::qmdb::current] state sync. //! -//! This module reuses the shared sync test functions from [crate::qmdb::any::sync::tests] -//! by implementing [SyncTestHarness] for current database types. The key difference from -//! `any` harnesses is that `sync_target_root` returns the **ops root** (via +//! This module reuses the shared sync test functions from [crate::qmdb::any::sync::tests] by +//! implementing [SyncTestHarness] for current database types. The key difference from `any` +//! harnesses is that `sync_target_root` returns the **ops root** (via //! [qmdb::sync::Database::root](crate::qmdb::sync::Database::root)), not the canonical root //! returned by `Db::root()`. //! //! Harnesses are instantiated for **both** MMR and MMB merkle families across each (ordered, -//! unordered) x (fixed, variable) database variant, so the shared suite runs twice per -//! variant. +//! unordered) x (fixed, variable) database variant, so the shared suite runs twice per variant. //! //! In addition to the shared harness-based suite, this module contains focused tests for -//! `current`-specific sync behavior: overlay-state authentication (canonical-root check), -//! pruned MMB round-trip, and target-update regression coverage. +//! `current`-specific sync behavior: overlay-state authentication (canonical-root check), pruned +//! MMB round-trip, and target-update regression coverage. use crate::qmdb::{ any::sync::tests::{ConfigOf, SyncTestHarness}, diff --git a/storage/src/qmdb/immutable/sync.rs b/storage/src/qmdb/immutable/sync.rs deleted file mode 100644 index 6a55c95e671..00000000000 --- a/storage/src/qmdb/immutable/sync.rs +++ /dev/null @@ -1,1014 +0,0 @@ -use crate::{ - index::unordered::Index, - journal::{ - authenticated, - contiguous::{Mutable, Reader as _}, - Error as JournalError, - }, - merkle::{ - journaled::{self, Journaled}, - Family, Location, - }, - qmdb::{ - any::ValueEncoding, - build_snapshot_from_log, - immutable::{self, Operation}, - operation::Key, - sync::{self}, - Error, - }, - translator::Translator, - Context, Persistable, -}; -use commonware_codec::EncodeShared; -use commonware_cryptography::Hasher; -use commonware_utils::range::NonEmptyRange; - -type StandardHasher = crate::merkle::hasher::Standard; - -impl sync::Database for immutable::Immutable -where - F: Family, - E: Context, - K: Key, - V: ValueEncoding, - C: Mutable> - + Persistable - + sync::Journal>, - C::Item: EncodeShared, - C::Config: Clone + Send, - H: Hasher, - T: Translator, -{ - type Family = F; - type Op = Operation; - type Journal = C; - type Hasher = H; - type Config = immutable::Config; - type Digest = H::Digest; - type Context = E; - - /// Returns an [Immutable](immutable::Immutable) initialized from data collected in the sync process. - /// - /// # Behavior - /// - /// This method handles different initialization scenarios based on existing data: - /// - If the Merkle journal is empty or the last item is before the range start, it creates a - /// fresh Merkle structure from the provided `pinned_nodes` - /// - If the Merkle journal has data but is incomplete (has length < range end), missing - /// operations from the log are applied to bring it up to the target state - /// - If the Merkle journal has data beyond the range end, it is rewound to match the sync - /// target - /// - /// # Returns - /// - /// A [super::Immutable] db populated with the state from the given range. - /// The pruning boundary is set to the range start. - async fn from_sync_result( - context: Self::Context, - db_config: Self::Config, - log: Self::Journal, - pinned_nodes: Option>, - range: NonEmptyRange>, - apply_batch_size: usize, - ) -> Result> { - let hasher = StandardHasher::new(); - - // Initialize Merkle structure for sync - let merkle = Journaled::init_sync( - context.with_label("merkle"), - journaled::SyncConfig { - config: db_config.merkle_config.clone(), - range, - pinned_nodes, - }, - &hasher, - ) - .await?; - - let journal = authenticated::Journal::<_, _, _, _>::from_components( - merkle, - log, - hasher, - apply_batch_size as u64, - ) - .await?; - - let mut snapshot: Index> = - Index::new(context.with_label("snapshot"), db_config.translator.clone()); - - let last_commit_loc = { - // Get the start of the log. - let reader = journal.journal.reader().await; - let bounds = reader.bounds(); - let start_loc = Location::::new(bounds.start); - - // Build snapshot from the log - build_snapshot_from_log::(start_loc, &reader, &mut snapshot, |_, _| {}) - .await?; - - Location::new(bounds.end.checked_sub(1).expect("commit should exist")) - }; - - let db = Self { - journal, - snapshot, - last_commit_loc, - }; - - db.sync().await?; - Ok(db) - } - - fn root(&self) -> Self::Digest { - self.root() - } -} - -#[cfg(test)] -mod tests { - use crate::{ - merkle::mmr::Location, - qmdb::{ - immutable, - immutable::variable::Operation, - sync::{ - self, - engine::{Config, NextStep}, - Engine, Target, - }, - }, - translator::TwoCap, - }; - use commonware_cryptography::{sha256, Sha256}; - use commonware_macros::test_traced; - use commonware_math::algebra::Random; - use commonware_runtime::{ - buffer::paged::CacheRef, deterministic, BufferPooler, Metrics, Runner as _, - }; - use commonware_utils::{ - channel::mpsc, non_empty_range, test_rng_seeded, NZUsize, NZU16, NZU64, - }; - use rand::RngCore as _; - use rstest::rstest; - use std::{ - collections::HashMap, - num::{NonZeroU16, NonZeroU64, NonZeroUsize}, - sync::Arc, - }; - - /// Type alias for sync tests with simple codec config - type ImmutableSyncTest = immutable::variable::Db< - crate::merkle::mmr::Family, - deterministic::Context, - sha256::Digest, - sha256::Digest, - Sha256, - crate::translator::TwoCap, - >; - - /// Create a simple config for sync tests - fn create_sync_config( - suffix: &str, - pooler: &impl BufferPooler, - ) -> immutable::variable::Config { - const PAGE_SIZE: NonZeroU16 = NZU16!(77); - const PAGE_CACHE_SIZE: NonZeroUsize = NZUsize!(9); - const ITEMS_PER_SECTION: NonZeroU64 = NZU64!(5); - - let page_cache = CacheRef::from_pooler(pooler, PAGE_SIZE, PAGE_CACHE_SIZE); - immutable::Config { - merkle_config: crate::merkle::journaled::Config { - journal_partition: format!("journal-{suffix}"), - metadata_partition: format!("metadata-{suffix}"), - items_per_blob: NZU64!(11), - write_buffer: NZUsize!(1024), - thread_pool: None, - page_cache: page_cache.clone(), - }, - log: crate::journal::contiguous::variable::Config { - partition: format!("log-{suffix}"), - items_per_section: ITEMS_PER_SECTION, - compression: None, - codec_config: ((), ()), - page_cache, - write_buffer: NZUsize!(1024), - }, - translator: TwoCap, - } - } - - /// Create a test database with unique partition names - async fn create_test_db(mut context: deterministic::Context) -> ImmutableSyncTest { - let seed = context.next_u64(); - let config = create_sync_config(&format!("sync-test-{seed}"), &context); - ImmutableSyncTest::init(context, config).await.unwrap() - } - - /// Create n random Set operations using the default seed (0). - /// create_test_ops(n) is a prefix of create_test_ops(n') for n < n'. - fn create_test_ops(n: usize) -> Vec> { - create_test_ops_seeded(n, 0) - } - - /// Create n random Set operations using a specific seed. - /// Use different seeds when you need non-overlapping keys in the same test. - fn create_test_ops_seeded( - n: usize, - seed: u64, - ) -> Vec> { - let mut rng = test_rng_seeded(seed); - let mut ops = Vec::new(); - for _i in 0..n { - let key = sha256::Digest::random(&mut rng); - let value = sha256::Digest::random(&mut rng); - ops.push(Operation::Set(key, value)); - } - ops - } - - /// Applies the given operations and commits the database. - async fn apply_ops( - db: &mut ImmutableSyncTest, - ops: Vec>, - metadata: Option, - ) { - let mut batch = db.new_batch(); - for op in ops { - match op { - Operation::Set(key, value) => { - batch = batch.set(key, value); - } - Operation::Commit(_metadata) => { - panic!("Commit operation not supported in apply_ops"); - } - } - } - let merkleized = batch.merkleize(db, metadata); - db.apply_batch(merkleized).await.unwrap(); - } - - #[rstest] - #[case::singleton_batch_size_one(1, NZU64!(1))] - #[case::singleton_batch_size_gt_db_size(1, NZU64!(2))] - #[case::batch_size_one(1000, NZU64!(1))] - #[case::floor_div_db_batch_size(1000, NZU64!(3))] - #[case::floor_div_db_batch_size_2(1000, NZU64!(999))] - #[case::div_db_batch_size(1000, NZU64!(100))] - #[case::db_size_eq_batch_size(1000, NZU64!(1000))] - #[case::batch_size_gt_db_size(1000, NZU64!(1001))] - fn test_sync(#[case] target_db_ops: usize, #[case] fetch_batch_size: NonZeroU64) { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let mut target_db = create_test_db(context.with_label("target")).await; - let target_db_ops = create_test_ops(target_db_ops); - apply_ops(&mut target_db, target_db_ops.clone(), Some(Sha256::fill(1))).await; - let bounds = target_db.bounds().await; - let target_op_count = bounds.end; - let target_oldest_retained_loc = bounds.start; - let target_root = target_db.root(); - - // Capture target database state before moving into config - let mut expected_kvs: HashMap = HashMap::new(); - for op in &target_db_ops { - if let Operation::Set(key, value) = op { - expected_kvs.insert(*key, *value); - } - } - - let db_config = - create_sync_config(&format!("sync_client_{}", context.next_u64()), &context); - - let target_db = Arc::new(target_db); - let config = Config { - db_config: db_config.clone(), - fetch_batch_size, - target: Target { - root: target_root, - range: non_empty_range!(target_oldest_retained_loc, target_op_count), - }, - context: context.with_label("client"), - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let got_db: ImmutableSyncTest = sync::sync(config).await.unwrap(); - - // Verify database state - let bounds = got_db.bounds().await; - assert_eq!(bounds.end, target_op_count); - assert_eq!(bounds.start, target_oldest_retained_loc); - - // Verify the root digest matches the target - assert_eq!(got_db.root(), target_root); - - // Verify that the synced database matches the target state - for (key, expected_value) in &expected_kvs { - let synced_value = got_db.get(key).await.unwrap(); - assert_eq!(synced_value, Some(*expected_value)); - } - - // Put more key-value pairs into both databases - let mut new_ops = Vec::new(); - let mut rng = test_rng_seeded(1); - let mut new_kvs: HashMap = HashMap::new(); - for _i in 0..expected_kvs.len() { - let key = sha256::Digest::random(&mut rng); - let value = sha256::Digest::random(&mut rng); - new_ops.push(Operation::Set(key, value)); - new_kvs.insert(key, value); - } - - // Apply new operations to both databases. - let mut got_db = got_db; - apply_ops(&mut got_db, new_ops.clone(), None).await; - let mut target_db = Arc::try_unwrap(target_db) - .unwrap_or_else(|_| panic!("target_db should have no other references")); - apply_ops(&mut target_db, new_ops.clone(), None).await; - - // Verify both databases have the new values - for (key, expected_value) in &new_kvs { - let synced_value = got_db.get(key).await.unwrap(); - assert_eq!(synced_value, Some(*expected_value)); - let target_value = target_db.get(key).await.unwrap(); - assert_eq!(target_value, Some(*expected_value)); - } - - got_db.destroy().await.unwrap(); - target_db.destroy().await.unwrap(); - }); - } - - /// Test that sync works when the target database is initially empty - #[test_traced("WARN")] - fn test_sync_empty_to_nonempty() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - // Create an empty target database - let mut target_db = create_test_db(context.with_label("target")).await; - // Commit to establish a valid root - apply_ops(&mut target_db, vec![], Some(Sha256::fill(1))).await; - - let bounds = target_db.bounds().await; - let target_op_count = bounds.end; - let target_oldest_retained_loc = bounds.start; - let target_root = target_db.root(); - - let db_config = - create_sync_config(&format!("empty_sync_{}", context.next_u64()), &context); - let target_db = Arc::new(target_db); - let config = Config { - db_config, - fetch_batch_size: NZU64!(10), - target: Target { - root: target_root, - range: non_empty_range!(target_oldest_retained_loc, target_op_count), - }, - context: context.with_label("client"), - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let got_db: ImmutableSyncTest = sync::sync(config).await.unwrap(); - - // Verify database state - let bounds = got_db.bounds().await; - assert_eq!(bounds.end, target_op_count); - assert_eq!(bounds.start, target_oldest_retained_loc); - assert_eq!(got_db.root(), target_root); - assert_eq!(got_db.get_metadata().await.unwrap(), Some(Sha256::fill(1))); - - got_db.destroy().await.unwrap(); - let target_db = Arc::try_unwrap(target_db) - .unwrap_or_else(|_| panic!("Failed to unwrap Arc - still has references")); - target_db.destroy().await.unwrap(); - }); - } - - /// Test demonstrating that a synced database can be reopened and retain its state. - #[test_traced("WARN")] - fn test_sync_database_persistence() { - let executor = deterministic::Runner::default(); - executor.start(|context| async move { - // Create and populate a simple target database - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(10); - apply_ops(&mut target_db, target_ops.clone(), Some(Sha256::fill(0))).await; - - // Capture target state - let target_root = target_db.root(); - let bounds = target_db.bounds().await; - let lower_bound = bounds.start; - let op_count = bounds.end; - - // Perform sync - let db_config = create_sync_config("persistence-test", &context); - let client_context = context.with_label("client"); - let target_db = Arc::new(target_db); - let config = Config { - db_config: db_config.clone(), - fetch_batch_size: NZU64!(5), - target: Target { - root: target_root, - range: non_empty_range!(lower_bound, op_count), - }, - context: client_context.clone(), - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let synced_db: ImmutableSyncTest = sync::sync(config).await.unwrap(); - - // Verify initial sync worked - assert_eq!(synced_db.root(), target_root); - - // Save state before closing - let expected_root = synced_db.root(); - let bounds = synced_db.bounds().await; - let expected_op_count = bounds.end; - let expected_oldest_retained_loc = bounds.start; - - // Drop & reopen the database to test persistence - synced_db.sync().await.unwrap(); - drop(synced_db); - let reopened_db = ImmutableSyncTest::init(context.with_label("reopened"), db_config) - .await - .unwrap(); - - // Verify state is preserved - assert_eq!(reopened_db.root(), expected_root); - let bounds = reopened_db.bounds().await; - assert_eq!(bounds.end, expected_op_count); - assert_eq!(bounds.start, expected_oldest_retained_loc); - - // Verify data integrity - for op in &target_ops { - if let Operation::Set(key, value) = op { - let stored_value = reopened_db.get(key).await.unwrap(); - assert_eq!(stored_value, Some(*value)); - } - } - - reopened_db.destroy().await.unwrap(); - let target_db = Arc::try_unwrap(target_db) - .unwrap_or_else(|_| panic!("Failed to unwrap Arc - still has references")); - target_db.destroy().await.unwrap(); - }); - } - - /// Test that target updates work correctly during sync - #[test_traced("WARN")] - fn test_target_update_during_sync() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - // Create and populate initial target database - let mut target_db = create_test_db(context.with_label("target")).await; - let initial_ops = create_test_ops(50); - apply_ops(&mut target_db, initial_ops.clone(), None).await; - - // Capture the state after first commit - let bounds = target_db.bounds().await; - let initial_lower_bound = bounds.start; - let initial_upper_bound = bounds.end; - let initial_root = target_db.root(); - - // Add more operations to create the extended target - // (use different seed to avoid key collisions) - let additional_ops = create_test_ops_seeded(25, 1); - apply_ops(&mut target_db, additional_ops.clone(), None).await; - let final_upper_bound = target_db.bounds().await.end; - let final_root = target_db.root(); - - // Wrap target database for shared mutable access - let target_db = Arc::new(target_db); - - // Create client with initial smaller target and very small batch size - let (update_sender, update_receiver) = mpsc::channel(1); - let client = { - let config = Config { - context: context.with_label("client"), - db_config: create_sync_config( - &format!("update_test_{}", context.next_u64()), - &context, - ), - target: Target { - root: initial_root, - range: non_empty_range!(initial_lower_bound, initial_upper_bound), - }, - resolver: target_db.clone(), - fetch_batch_size: NZU64!(2), // Very small batch size to ensure multiple batches needed - max_outstanding_requests: 10, - apply_batch_size: 1024, - update_rx: Some(update_receiver), - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 1, - }; - let mut client: Engine = Engine::new(config).await.unwrap(); - loop { - // Step the client until we have processed a batch of operations - client = match client.step().await.unwrap() { - NextStep::Continue(new_client) => new_client, - NextStep::Complete(_) => panic!("client should not be complete"), - }; - let log_size = - crate::journal::contiguous::Contiguous::size(client.journal()).await; - if log_size > initial_lower_bound { - break client; - } - } - }; - - // Send target update with SAME lower bound but higher upper bound - update_sender - .send(Target { - root: final_root, - range: non_empty_range!(initial_lower_bound, final_upper_bound), - }) - .await - .unwrap(); - - // Complete the sync - let synced_db = client.sync().await.unwrap(); - - // Verify the synced database has the expected final state - assert_eq!(synced_db.root(), final_root); - - // Verify the target database matches the synced database - let target_db = Arc::try_unwrap(target_db) - .unwrap_or_else(|_| panic!("Failed to unwrap Arc - still has references")); - { - let bounds = synced_db.bounds().await; - let target_bounds = target_db.bounds().await; - assert_eq!(bounds.end, target_bounds.end); - assert_eq!(bounds.start, target_bounds.start); - assert_eq!(synced_db.root(), target_db.root()); - } - - // Verify all expected operations are present in the synced database - let all_ops = [initial_ops, additional_ops].concat(); - for op in &all_ops { - if let Operation::Set(key, value) = op { - let synced_value = synced_db.get(key).await.unwrap(); - assert_eq!(synced_value, Some(*value)); - } - } - - synced_db.destroy().await.unwrap(); - target_db.destroy().await.unwrap(); - }); - } - - /// Test that sync works when target database has operations beyond the requested range - /// of operations to sync. - #[test] - fn test_sync_subset_of_target_database() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(30); - // Apply all but the last operation - apply_ops(&mut target_db, target_ops[..29].to_vec(), None).await; - - let target_root = target_db.root(); - let bounds = target_db.bounds().await; - let lower_bound = bounds.start; - let op_count = bounds.end; - - // Add final op after capturing the range - apply_ops(&mut target_db, target_ops[29..].to_vec(), None).await; - - let target_db = Arc::new(target_db); - let config = Config { - db_config: create_sync_config(&format!("subset_{}", context.next_u64()), &context), - fetch_batch_size: NZU64!(10), - target: Target { - root: target_root, - range: non_empty_range!(lower_bound, op_count), - }, - context: context.with_label("client"), - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let synced_db: ImmutableSyncTest = sync::sync(config).await.unwrap(); - - // Verify state matches the specified range - assert_eq!(synced_db.root(), target_root); - assert_eq!(synced_db.bounds().await.end, op_count); - - synced_db.destroy().await.unwrap(); - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - // Test syncing where the sync client has some but not all of the operations in the target - // database. - #[test] - fn test_sync_use_existing_db_partial_match() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let original_ops = create_test_ops(50); - - // Create two databases - let mut target_db = create_test_db(context.with_label("target")).await; - let sync_db_config = - create_sync_config(&format!("partial_{}", context.next_u64()), &context); - let client_context = context.with_label("client"); - let mut sync_db: ImmutableSyncTest = - immutable::variable::Db::init(client_context.clone(), sync_db_config.clone()) - .await - .unwrap(); - - // Apply the same operations to both databases - apply_ops(&mut target_db, original_ops.clone(), None).await; - apply_ops(&mut sync_db, original_ops.clone(), None).await; - - drop(sync_db); - - // Add one more operation and commit the target database - // (use different seed to avoid key collisions) - let last_op = create_test_ops_seeded(1, 1); - apply_ops(&mut target_db, last_op.clone(), None).await; - let root = target_db.root(); - let bounds = target_db.bounds().await; - let lower_bound = bounds.start; - let upper_bound = bounds.end; // Up to the last operation - - // Reopen the sync database and sync it to the target database - let target_db = Arc::new(target_db); - let config = Config { - db_config: sync_db_config, // Use same config as before - fetch_batch_size: NZU64!(10), - target: Target { - root, - range: non_empty_range!(lower_bound, upper_bound), - }, - context: context.with_label("sync"), - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let sync_db: ImmutableSyncTest = sync::sync(config).await.unwrap(); - - // Verify database state - assert_eq!(sync_db.bounds().await.end, upper_bound); - assert_eq!(sync_db.root(), root); - - sync_db.destroy().await.unwrap(); - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - /// Test case where existing database on disk exactly matches the sync target - #[test] - fn test_sync_use_existing_db_exact_match() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let target_ops = create_test_ops(40); - - // Create two databases - let mut target_db = create_test_db(context.with_label("target")).await; - let sync_config = - create_sync_config(&format!("exact_{}", context.next_u64()), &context); - let client_context = context.with_label("client"); - let mut sync_db: ImmutableSyncTest = - immutable::variable::Db::init(client_context.clone(), sync_config.clone()) - .await - .unwrap(); - - // Apply the same operations to both databases - apply_ops(&mut target_db, target_ops.clone(), None).await; - apply_ops(&mut sync_db, target_ops.clone(), None).await; - - drop(sync_db); - - // Prepare target - let root = target_db.root(); - let bounds = target_db.bounds().await; - let lower_bound = bounds.start; - let upper_bound = bounds.end; - - // Sync should complete immediately without fetching - let resolver = Arc::new(target_db); - let config = Config { - db_config: sync_config, - fetch_batch_size: NZU64!(10), - target: Target { - root, - range: non_empty_range!(lower_bound, upper_bound), - }, - context: context.with_label("sync"), - resolver: resolver.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let sync_db: ImmutableSyncTest = sync::sync(config).await.unwrap(); - - assert_eq!(sync_db.bounds().await.end, upper_bound); - assert_eq!(sync_db.root(), root); - - sync_db.destroy().await.unwrap(); - let target_db = - Arc::try_unwrap(resolver).unwrap_or_else(|_| panic!("failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - /// Test that the client fails to sync if the lower bound is decreased - #[test_traced("WARN")] - fn test_target_update_lower_bound_decrease() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - // Create and populate target database - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(100); - apply_ops(&mut target_db, target_ops, None).await; - - target_db.prune(Location::new(10)).await.unwrap(); - - // Capture initial target state - let bounds = target_db.bounds().await; - let initial_lower_bound = bounds.start; - let initial_upper_bound = bounds.end; - let initial_root = target_db.root(); - - // Create client with initial target - let (update_sender, update_receiver) = mpsc::channel(1); - let target_db = Arc::new(target_db); - let config = Config { - context: context.with_label("client"), - db_config: create_sync_config(&format!("lb-dec-{}", context.next_u64()), &context), - fetch_batch_size: NZU64!(5), - target: Target { - root: initial_root, - range: non_empty_range!(initial_lower_bound, initial_upper_bound), - }, - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 10, - update_rx: Some(update_receiver), - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 1, - }; - let client: Engine = Engine::new(config).await.unwrap(); - - // Send target update with decreased lower bound - update_sender - .send(Target { - root: initial_root, - range: non_empty_range!( - initial_lower_bound.checked_sub(1).unwrap(), - initial_upper_bound - ), - }) - .await - .unwrap(); - - let result = client.step().await; - assert!(matches!( - result, - Err(sync::Error::Engine( - sync::EngineError::SyncTargetMovedBackward { .. } - )) - )); - - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - /// Test that the client fails to sync if the upper bound is decreased - #[test_traced("WARN")] - fn test_target_update_upper_bound_decrease() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - // Create and populate target database - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(50); - apply_ops(&mut target_db, target_ops, None).await; - - // Capture initial target state - let bounds = target_db.bounds().await; - let initial_lower_bound = bounds.start; - let initial_upper_bound = bounds.end; - let initial_root = target_db.root(); - - // Create client with initial target - let (update_sender, update_receiver) = mpsc::channel(1); - let target_db = Arc::new(target_db); - let config = Config { - context: context.with_label("client"), - db_config: create_sync_config(&format!("ub-dec-{}", context.next_u64()), &context), - fetch_batch_size: NZU64!(5), - target: Target { - root: initial_root, - range: non_empty_range!(initial_lower_bound, initial_upper_bound), - }, - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 10, - update_rx: Some(update_receiver), - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 1, - }; - let client: Engine = Engine::new(config).await.unwrap(); - - // Send target update with decreased upper bound - update_sender - .send(Target { - root: initial_root, - range: non_empty_range!(initial_lower_bound, initial_upper_bound - 1), - }) - .await - .unwrap(); - - let result = client.step().await; - assert!(matches!( - result, - Err(sync::Error::Engine( - sync::EngineError::SyncTargetMovedBackward { .. } - )) - )); - - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - /// Test that the client succeeds when bounds are updated - #[test_traced("WARN")] - fn test_target_update_bounds_increase() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - // Create and populate target database - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(100); - apply_ops(&mut target_db, target_ops.clone(), None).await; - - // Capture initial target state - let bounds = target_db.bounds().await; - let initial_lower_bound = bounds.start; - let initial_upper_bound = bounds.end; - let initial_root = target_db.root(); - - // Apply more operations to the target database - // (use different seed to avoid key collisions) - let more_ops = create_test_ops_seeded(5, 1); - apply_ops(&mut target_db, more_ops, None).await; - - target_db.prune(Location::new(10)).await.unwrap(); - apply_ops(&mut target_db, vec![], None).await; - - // Capture final target state - let bounds = target_db.bounds().await; - let final_lower_bound = bounds.start; - let final_upper_bound = bounds.end; - let final_root = target_db.root(); - - // Assert we're actually updating the bounds - assert_ne!(final_lower_bound, initial_lower_bound); - assert_ne!(final_upper_bound, initial_upper_bound); - - // Create client with initial target - let (update_sender, update_receiver) = mpsc::channel(1); - let target_db = Arc::new(target_db); - let config = Config { - context: context.with_label("client"), - db_config: create_sync_config( - &format!("bounds_inc_{}", context.next_u64()), - &context, - ), - fetch_batch_size: NZU64!(1), - target: Target { - root: initial_root, - range: non_empty_range!(initial_lower_bound, initial_upper_bound), - }, - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: Some(update_receiver), - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 1, - }; - - // Send target update with increased upper bound - update_sender - .send(Target { - root: final_root, - range: non_empty_range!(final_lower_bound, final_upper_bound), - }) - .await - .unwrap(); - - // Complete the sync - let synced_db: ImmutableSyncTest = sync::sync(config).await.unwrap(); - - // Verify the synced database has the expected state - assert_eq!(synced_db.root(), final_root); - let bounds = synced_db.bounds().await; - assert_eq!(bounds.end, final_upper_bound); - assert_eq!(bounds.start, final_lower_bound); - - synced_db.destroy().await.unwrap(); - let target_db = Arc::try_unwrap(target_db) - .unwrap_or_else(|_| panic!("Failed to unwrap Arc - still has references")); - target_db.destroy().await.unwrap(); - }); - } - - /// Test that target updates can be sent even after the client is done - #[test_traced("WARN")] - fn test_target_update_on_done_client() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - // Create and populate target database - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(10); - apply_ops(&mut target_db, target_ops, None).await; - - // Capture target state - let bounds = target_db.bounds().await; - let lower_bound = bounds.start; - let upper_bound = bounds.end; - let root = target_db.root(); - - // Create client with target that will complete immediately - let (update_sender, update_receiver) = mpsc::channel(1); - let target_db = Arc::new(target_db); - let config = Config { - context: context.with_label("client"), - db_config: create_sync_config(&format!("done_{}", context.next_u64()), &context), - fetch_batch_size: NZU64!(20), - target: Target { - root, - range: non_empty_range!(lower_bound, upper_bound), - }, - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 10, - update_rx: Some(update_receiver), - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 1, - }; - - // Complete the sync - let synced_db: ImmutableSyncTest = sync::sync(config).await.unwrap(); - - // Attempt to apply a target update after sync is complete to verify we don't panic - let _ = update_sender - .send(Target { - root: sha256::Digest::from([2u8; 32]), - range: non_empty_range!(lower_bound + 1, upper_bound + 1), - }) - .await; - - // Verify the synced database has the expected state - assert_eq!(synced_db.root(), root); - let bounds = synced_db.bounds().await; - assert_eq!(bounds.end, upper_bound); - assert_eq!(bounds.start, lower_bound); - - synced_db.destroy().await.unwrap(); - Arc::try_unwrap(target_db) - .unwrap_or_else(|_| panic!("failed to unwrap Arc")) - .destroy() - .await - .unwrap(); - }); - } -} diff --git a/storage/src/qmdb/immutable/sync/mod.rs b/storage/src/qmdb/immutable/sync/mod.rs new file mode 100644 index 00000000000..2076b5b2223 --- /dev/null +++ b/storage/src/qmdb/immutable/sync/mod.rs @@ -0,0 +1,129 @@ +use crate::{ + index::unordered::Index, + journal::{ + authenticated, + contiguous::{Mutable, Reader as _}, + Error as JournalError, + }, + merkle::{ + journaled::{self, Journaled}, + Family, Location, + }, + qmdb::{ + any::ValueEncoding, + build_snapshot_from_log, + immutable::{self, Operation}, + operation::Key, + sync::{self}, + Error, + }, + translator::Translator, + Context, Persistable, +}; +use commonware_codec::EncodeShared; +use commonware_cryptography::Hasher; +use commonware_utils::range::NonEmptyRange; + +type StandardHasher = crate::merkle::hasher::Standard; + +impl sync::Database for immutable::Immutable +where + F: Family, + E: Context, + K: Key, + V: ValueEncoding, + C: Mutable> + + Persistable + + sync::Journal>, + C::Item: EncodeShared, + C::Config: Clone + Send, + H: Hasher, + T: Translator, +{ + type Family = F; + type Op = Operation; + type Journal = C; + type Hasher = H; + type Config = immutable::Config; + type Digest = H::Digest; + type Context = E; + + /// Returns an [Immutable](immutable::Immutable) initialized from data collected in the sync process. + /// + /// # Behavior + /// + /// This method handles different initialization scenarios based on existing data: + /// - If the Merkle journal is empty or the last item is before the range start, it creates a + /// fresh Merkle structure from the provided `pinned_nodes` + /// - If the Merkle journal has data but is incomplete (has length < range end), missing + /// operations from the log are applied to bring it up to the target state + /// - If the Merkle journal has data beyond the range end, it is rewound to match the sync + /// target + /// + /// # Returns + /// + /// A [super::Immutable] db populated with the state from the given range. + /// The pruning boundary is set to the range start. + async fn from_sync_result( + context: Self::Context, + db_config: Self::Config, + log: Self::Journal, + pinned_nodes: Option>, + range: NonEmptyRange>, + apply_batch_size: usize, + ) -> Result> { + let hasher = StandardHasher::new(); + + // Initialize Merkle structure for sync + let merkle = Journaled::init_sync( + context.with_label("merkle"), + journaled::SyncConfig { + config: db_config.merkle_config.clone(), + range, + pinned_nodes, + }, + &hasher, + ) + .await?; + + let journal = authenticated::Journal::<_, _, _, _>::from_components( + merkle, + log, + hasher, + apply_batch_size as u64, + ) + .await?; + + let mut snapshot: Index> = + Index::new(context.with_label("snapshot"), db_config.translator.clone()); + + let last_commit_loc = { + // Get the start of the log. + let reader = journal.journal.reader().await; + let bounds = reader.bounds(); + let start_loc = Location::::new(bounds.start); + + // Build snapshot from the log + build_snapshot_from_log::(start_loc, &reader, &mut snapshot, |_, _| {}) + .await?; + + Location::new(bounds.end.checked_sub(1).expect("commit should exist")) + }; + + let db = Self { + journal, + snapshot, + last_commit_loc, + }; + + db.sync().await?; + Ok(db) + } + + fn root(&self) -> Self::Digest { + self.root() + } +} + +#[cfg(test)] +mod tests; diff --git a/storage/src/qmdb/immutable/sync/tests.rs b/storage/src/qmdb/immutable/sync/tests.rs new file mode 100644 index 00000000000..77a806e8b9b --- /dev/null +++ b/storage/src/qmdb/immutable/sync/tests.rs @@ -0,0 +1,1016 @@ +//! Generic sync tests for immutable databases. +//! +//! This module defines a [`SyncTestHarness`] trait and generic test functions parameterized +//! over the harness, so the same tests can run against any combination of merkle family +//! (MMR, MMB) and database variant. Per-harness concrete `#[test]` functions are expanded +//! by the [`sync_tests_for_harness!`] macro. + +use crate::{ + journal::contiguous::Contiguous, + merkle::{self, journaled::Config as MerkleConfig, Location}, + qmdb::{ + self, + immutable::{self, variable::Operation}, + sync::{ + self, + engine::{Config, NextStep}, + resolver::Resolver, + Engine, Target, + }, + }, + translator::TwoCap, +}; +use commonware_codec::Encode; +use commonware_cryptography::{sha256, Sha256}; +use commonware_math::algebra::Random; +use commonware_runtime::{ + buffer::paged::CacheRef, deterministic, BufferPooler, Metrics, Runner as _, +}; +use commonware_utils::{channel::mpsc, non_empty_range, test_rng_seeded, NZUsize, NZU16, NZU64}; +use rand::RngCore as _; +use std::{ + collections::HashMap, + future::Future, + num::{NonZeroU16, NonZeroU64, NonZeroUsize}, + sync::Arc, +}; + +pub(crate) type DbOf = ::Db; +pub(crate) type OpOf = as qmdb::sync::Database>::Op; +pub(crate) type ConfigOf = as qmdb::sync::Database>::Config; +pub(crate) type FamilyOf = as qmdb::sync::Database>::Family; +pub(crate) type JournalOf = as qmdb::sync::Database>::Journal; + +const PAGE_SIZE: NonZeroU16 = NZU16!(77); +const PAGE_CACHE_SIZE: NonZeroUsize = NZUsize!(9); + +/// Harness that abstracts per-family details so the generic tests below can operate on +/// any immutable database. +pub(crate) trait SyncTestHarness: Sized + 'static { + type Family: merkle::Family; + type Db: qmdb::sync::Database< + Family = Self::Family, + Context = deterministic::Context, + Digest = sha256::Digest, + Config: Clone, + > + Send + + Sync; + type Key: Clone + Eq + std::hash::Hash + Send + Sync + 'static; + type Value: Clone + PartialEq + std::fmt::Debug + Send + Sync + 'static; + type Metadata: Clone + PartialEq + std::fmt::Debug + Send + Sync + 'static; + + fn config(suffix: &str, pooler: &(impl BufferPooler + Metrics)) -> ConfigOf; + fn create_ops(n: usize) -> Vec>; + fn create_ops_seeded(n: usize, seed: u64) -> Vec>; + fn sample_metadata() -> Self::Metadata; + + fn init_db(ctx: deterministic::Context) -> impl Future + Send; + fn init_db_with_config( + ctx: deterministic::Context, + config: ConfigOf, + ) -> impl Future + Send; + fn destroy(db: Self::Db) -> impl Future + Send; + fn db_sync(db: &Self::Db) -> impl Future + Send; + + fn apply_ops( + db: Self::Db, + ops: Vec>, + metadata: Option, + ) -> impl Future + Send; + fn prune(db: &mut Self::Db, loc: Location) -> impl Future + Send; + + fn bounds( + db: &Self::Db, + ) -> impl Future>> + Send; + fn db_root(db: &Self::Db) -> sha256::Digest; + fn get_metadata(db: &Self::Db) -> impl Future> + Send; + fn op_kv(op: &OpOf) -> Option<(&Self::Key, &Self::Value)>; + fn lookup(db: &Self::Db, key: &Self::Key) -> impl Future> + Send; +} + +// ===== Generic tests ===== + +pub(crate) fn test_sync(target_db_ops: usize, fetch_batch_size: NonZeroU64) +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(target_db_ops); + let target_db = + H::apply_ops(target_db, target_ops.clone(), Some(H::sample_metadata())).await; + let bounds = H::bounds(&target_db).await; + let target_op_count = bounds.end; + let target_oldest_retained_loc = bounds.start; + let target_root = H::db_root(&target_db); + + let mut expected_kvs: HashMap = HashMap::new(); + for op in &target_ops { + if let Some((key, value)) = H::op_kv(op) { + expected_kvs.insert(key.clone(), value.clone()); + } + } + + let db_config = H::config(&format!("sync_client_{}", context.next_u64()), &context); + + let target_db = Arc::new(target_db); + let config = Config { + db_config: db_config.clone(), + fetch_batch_size, + target: Target { + root: target_root, + range: non_empty_range!(target_oldest_retained_loc, target_op_count), + }, + context: context.with_label("client"), + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let got_db: DbOf = sync::sync(config).await.unwrap(); + + let bounds = H::bounds(&got_db).await; + assert_eq!(bounds.end, target_op_count); + assert_eq!(bounds.start, target_oldest_retained_loc); + assert_eq!(H::db_root(&got_db), target_root); + + for (key, expected_value) in &expected_kvs { + let synced_value = H::lookup(&got_db, key).await; + assert_eq!(synced_value, Some(expected_value.clone())); + } + + let new_ops = H::create_ops_seeded(target_db_ops, 1); + let got_db = H::apply_ops(got_db, new_ops.clone(), None).await; + let target_db = Arc::try_unwrap(target_db) + .unwrap_or_else(|_| panic!("target_db should have no other references")); + let target_db = H::apply_ops(target_db, new_ops, None).await; + + for key in expected_kvs.keys() { + let a = H::lookup(&got_db, key).await; + let b = H::lookup(&target_db, key).await; + assert_eq!(a, b); + } + + H::destroy(got_db).await; + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_sync_empty_to_nonempty() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_db = H::apply_ops(target_db, vec![], Some(H::sample_metadata())).await; + + let bounds = H::bounds(&target_db).await; + let target_op_count = bounds.end; + let target_oldest_retained_loc = bounds.start; + let target_root = H::db_root(&target_db); + + let db_config = H::config(&format!("empty_sync_{}", context.next_u64()), &context); + let target_db = Arc::new(target_db); + let config = Config { + db_config, + fetch_batch_size: NZU64!(10), + target: Target { + root: target_root, + range: non_empty_range!(target_oldest_retained_loc, target_op_count), + }, + context: context.with_label("client"), + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let got_db: DbOf = sync::sync(config).await.unwrap(); + + let bounds = H::bounds(&got_db).await; + assert_eq!(bounds.end, target_op_count); + assert_eq!(bounds.start, target_oldest_retained_loc); + assert_eq!(H::db_root(&got_db), target_root); + assert_eq!(H::get_metadata(&got_db).await, Some(H::sample_metadata())); + + H::destroy(got_db).await; + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_sync_database_persistence() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(10); + let target_db = + H::apply_ops(target_db, target_ops.clone(), Some(H::sample_metadata())).await; + + let target_root = H::db_root(&target_db); + let bounds = H::bounds(&target_db).await; + let lower_bound = bounds.start; + let op_count = bounds.end; + + let db_config = H::config("persistence-test", &context); + let client_context = context.with_label("client"); + let target_db = Arc::new(target_db); + let config = Config { + db_config: db_config.clone(), + fetch_batch_size: NZU64!(5), + target: Target { + root: target_root, + range: non_empty_range!(lower_bound, op_count), + }, + context: client_context.clone(), + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let synced_db: DbOf = sync::sync(config).await.unwrap(); + + assert_eq!(H::db_root(&synced_db), target_root); + let expected_root = H::db_root(&synced_db); + let bounds = H::bounds(&synced_db).await; + let expected_op_count = bounds.end; + let expected_oldest_retained_loc = bounds.start; + + H::db_sync(&synced_db).await; + drop(synced_db); + let reopened_db = H::init_db_with_config(context.with_label("reopened"), db_config).await; + + assert_eq!(H::db_root(&reopened_db), expected_root); + let bounds = H::bounds(&reopened_db).await; + assert_eq!(bounds.end, expected_op_count); + assert_eq!(bounds.start, expected_oldest_retained_loc); + + for op in &target_ops { + if let Some((key, expected_value)) = H::op_kv(op) { + let got = H::lookup(&reopened_db, key).await; + assert_eq!(got, Some(expected_value.clone())); + } + } + + H::destroy(reopened_db).await; + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_target_update_during_sync() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + JournalOf: Contiguous, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let initial_ops = H::create_ops(50); + let target_db = H::apply_ops(target_db, initial_ops.clone(), None).await; + + let bounds = H::bounds(&target_db).await; + let initial_lower_bound = bounds.start; + let initial_upper_bound = bounds.end; + let initial_root = H::db_root(&target_db); + + let additional_ops = H::create_ops_seeded(25, 1); + let target_db = H::apply_ops(target_db, additional_ops.clone(), None).await; + let final_upper_bound = H::bounds(&target_db).await.end; + let final_root = H::db_root(&target_db); + + let target_db = Arc::new(target_db); + + let (update_sender, update_receiver) = mpsc::channel(1); + let client = { + let config = Config { + context: context.with_label("client"), + db_config: H::config(&format!("update_test_{}", context.next_u64()), &context), + target: Target { + root: initial_root, + range: non_empty_range!(initial_lower_bound, initial_upper_bound), + }, + resolver: target_db.clone(), + fetch_batch_size: NZU64!(2), + max_outstanding_requests: 10, + apply_batch_size: 1024, + update_rx: Some(update_receiver), + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 1, + }; + let mut client: Engine, _> = Engine::new(config).await.unwrap(); + loop { + client = match client.step().await.unwrap() { + NextStep::Continue(new_client) => new_client, + NextStep::Complete(_) => panic!("client should not be complete"), + }; + let log_size = Contiguous::size(client.journal()).await; + if log_size > *initial_lower_bound { + break client; + } + } + }; + + update_sender + .send(Target { + root: final_root, + range: non_empty_range!(initial_lower_bound, final_upper_bound), + }) + .await + .unwrap(); + + let synced_db = client.sync().await.unwrap(); + assert_eq!(H::db_root(&synced_db), final_root); + + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); + { + let bounds = H::bounds(&synced_db).await; + let target_bounds = H::bounds(&target_db).await; + assert_eq!(bounds.end, target_bounds.end); + assert_eq!(bounds.start, target_bounds.start); + assert_eq!(H::db_root(&synced_db), H::db_root(&target_db)); + } + + let all_ops: Vec<_> = initial_ops.iter().chain(additional_ops.iter()).collect(); + for op in all_ops { + if let Some((key, expected_value)) = H::op_kv(op) { + let got = H::lookup(&synced_db, key).await; + assert_eq!(got, Some(expected_value.clone())); + } + } + + H::destroy(synced_db).await; + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_sync_subset_of_target_database() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(30); + let target_db = H::apply_ops(target_db, target_ops[..29].to_vec(), None).await; + + let target_root = H::db_root(&target_db); + let bounds = H::bounds(&target_db).await; + let lower_bound = bounds.start; + let op_count = bounds.end; + + let target_db = H::apply_ops(target_db, target_ops[29..].to_vec(), None).await; + + let target_db = Arc::new(target_db); + let config = Config { + db_config: H::config(&format!("subset_{}", context.next_u64()), &context), + fetch_batch_size: NZU64!(10), + target: Target { + root: target_root, + range: non_empty_range!(lower_bound, op_count), + }, + context: context.with_label("client"), + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let synced_db: DbOf = sync::sync(config).await.unwrap(); + + assert_eq!(H::db_root(&synced_db), target_root); + assert_eq!(H::bounds(&synced_db).await.end, op_count); + + H::destroy(synced_db).await; + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_sync_use_existing_db_partial_match() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let original_ops = H::create_ops(50); + + let target_db = H::init_db(context.with_label("target")).await; + let sync_db_config = H::config(&format!("partial_{}", context.next_u64()), &context); + let client_context = context.with_label("client"); + let sync_db = H::init_db_with_config(client_context.clone(), sync_db_config.clone()).await; + + let target_db = H::apply_ops(target_db, original_ops.clone(), None).await; + let sync_db = H::apply_ops(sync_db, original_ops, None).await; + drop(sync_db); + + let last_op = H::create_ops_seeded(1, 1); + let target_db = H::apply_ops(target_db, last_op, None).await; + let root = H::db_root(&target_db); + let bounds = H::bounds(&target_db).await; + let lower_bound = bounds.start; + let upper_bound = bounds.end; + + let target_db = Arc::new(target_db); + let config = Config { + db_config: sync_db_config, + fetch_batch_size: NZU64!(10), + target: Target { + root, + range: non_empty_range!(lower_bound, upper_bound), + }, + context: context.with_label("sync"), + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let sync_db: DbOf = sync::sync(config).await.unwrap(); + + assert_eq!(H::bounds(&sync_db).await.end, upper_bound); + assert_eq!(H::db_root(&sync_db), root); + + H::destroy(sync_db).await; + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_sync_use_existing_db_exact_match() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_ops = H::create_ops(40); + + let target_db = H::init_db(context.with_label("target")).await; + let sync_config = H::config(&format!("exact_{}", context.next_u64()), &context); + let client_context = context.with_label("client"); + let sync_db = H::init_db_with_config(client_context.clone(), sync_config.clone()).await; + + let target_db = H::apply_ops(target_db, target_ops.clone(), None).await; + let sync_db = H::apply_ops(sync_db, target_ops, None).await; + drop(sync_db); + + let root = H::db_root(&target_db); + let bounds = H::bounds(&target_db).await; + let lower_bound = bounds.start; + let upper_bound = bounds.end; + + let resolver = Arc::new(target_db); + let config = Config { + db_config: sync_config, + fetch_batch_size: NZU64!(10), + target: Target { + root, + range: non_empty_range!(lower_bound, upper_bound), + }, + context: context.with_label("sync"), + resolver: resolver.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let sync_db: DbOf = sync::sync(config).await.unwrap(); + + assert_eq!(H::bounds(&sync_db).await.end, upper_bound); + assert_eq!(H::db_root(&sync_db), root); + + H::destroy(sync_db).await; + let target_db = + Arc::try_unwrap(resolver).unwrap_or_else(|_| panic!("failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_target_update_lower_bound_decrease() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(100); + let mut target_db = H::apply_ops(target_db, target_ops, None).await; + + H::prune(&mut target_db, Location::new(10)).await; + + let bounds = H::bounds(&target_db).await; + let initial_lower_bound = bounds.start; + let initial_upper_bound = bounds.end; + let initial_root = H::db_root(&target_db); + + let (update_sender, update_receiver) = mpsc::channel(1); + let target_db = Arc::new(target_db); + let config = Config { + context: context.with_label("client"), + db_config: H::config(&format!("lb-dec-{}", context.next_u64()), &context), + fetch_batch_size: NZU64!(5), + target: Target { + root: initial_root, + range: non_empty_range!(initial_lower_bound, initial_upper_bound), + }, + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 10, + update_rx: Some(update_receiver), + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 1, + }; + let client: Engine, _> = Engine::new(config).await.unwrap(); + + update_sender + .send(Target { + root: initial_root, + range: non_empty_range!( + initial_lower_bound.checked_sub(1).unwrap(), + initial_upper_bound + ), + }) + .await + .unwrap(); + + let result = client.step().await; + assert!(matches!( + result, + Err(sync::Error::Engine( + sync::EngineError::SyncTargetMovedBackward { .. } + )) + )); + + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_target_update_upper_bound_decrease() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(50); + let target_db = H::apply_ops(target_db, target_ops, None).await; + + let bounds = H::bounds(&target_db).await; + let initial_lower_bound = bounds.start; + let initial_upper_bound = bounds.end; + let initial_root = H::db_root(&target_db); + + let (update_sender, update_receiver) = mpsc::channel(1); + let target_db = Arc::new(target_db); + let config = Config { + context: context.with_label("client"), + db_config: H::config(&format!("ub-dec-{}", context.next_u64()), &context), + fetch_batch_size: NZU64!(5), + target: Target { + root: initial_root, + range: non_empty_range!(initial_lower_bound, initial_upper_bound), + }, + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 10, + update_rx: Some(update_receiver), + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 1, + }; + let client: Engine, _> = Engine::new(config).await.unwrap(); + + update_sender + .send(Target { + root: initial_root, + range: non_empty_range!(initial_lower_bound, initial_upper_bound - 1), + }) + .await + .unwrap(); + + let result = client.step().await; + assert!(matches!( + result, + Err(sync::Error::Engine( + sync::EngineError::SyncTargetMovedBackward { .. } + )) + )); + + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_target_update_bounds_increase() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(100); + let target_db = H::apply_ops(target_db, target_ops, None).await; + + let bounds = H::bounds(&target_db).await; + let initial_lower_bound = bounds.start; + let initial_upper_bound = bounds.end; + let initial_root = H::db_root(&target_db); + + let more_ops = H::create_ops_seeded(5, 1); + let mut target_db = H::apply_ops(target_db, more_ops, None).await; + + H::prune(&mut target_db, Location::new(10)).await; + let target_db = H::apply_ops(target_db, vec![], None).await; + + let bounds = H::bounds(&target_db).await; + let final_lower_bound = bounds.start; + let final_upper_bound = bounds.end; + let final_root = H::db_root(&target_db); + + assert_ne!(final_lower_bound, initial_lower_bound); + assert_ne!(final_upper_bound, initial_upper_bound); + + let (update_sender, update_receiver) = mpsc::channel(1); + let target_db = Arc::new(target_db); + let config = Config { + context: context.with_label("client"), + db_config: H::config(&format!("bounds_inc_{}", context.next_u64()), &context), + fetch_batch_size: NZU64!(1), + target: Target { + root: initial_root, + range: non_empty_range!(initial_lower_bound, initial_upper_bound), + }, + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: Some(update_receiver), + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 1, + }; + + update_sender + .send(Target { + root: final_root, + range: non_empty_range!(final_lower_bound, final_upper_bound), + }) + .await + .unwrap(); + + let synced_db: DbOf = sync::sync(config).await.unwrap(); + + assert_eq!(H::db_root(&synced_db), final_root); + let bounds = H::bounds(&synced_db).await; + assert_eq!(bounds.end, final_upper_bound); + assert_eq!(bounds.start, final_lower_bound); + + H::destroy(synced_db).await; + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_target_update_on_done_client() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(10); + let target_db = H::apply_ops(target_db, target_ops, None).await; + + let bounds = H::bounds(&target_db).await; + let lower_bound = bounds.start; + let upper_bound = bounds.end; + let root = H::db_root(&target_db); + + let (update_sender, update_receiver) = mpsc::channel(1); + let target_db = Arc::new(target_db); + let config = Config { + context: context.with_label("client"), + db_config: H::config(&format!("done_{}", context.next_u64()), &context), + fetch_batch_size: NZU64!(20), + target: Target { + root, + range: non_empty_range!(lower_bound, upper_bound), + }, + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 10, + update_rx: Some(update_receiver), + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 1, + }; + + let synced_db: DbOf = sync::sync(config).await.unwrap(); + + let _ = update_sender + .send(Target { + root: sha256::Digest::from([2u8; 32]), + range: non_empty_range!(lower_bound + 1, upper_bound + 1), + }) + .await; + + assert_eq!(H::db_root(&synced_db), root); + let bounds = H::bounds(&synced_db).await; + assert_eq!(bounds.end, upper_bound); + assert_eq!(bounds.start, lower_bound); + + H::destroy(synced_db).await; + H::destroy(Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc"))) + .await; + }); +} + +// ===== Harness implementations ===== + +pub(crate) mod harnesses { + use super::*; + use crate::merkle::{mmb, mmr, Family}; + + type VariableDb = immutable::variable::Db< + F, + deterministic::Context, + sha256::Digest, + sha256::Digest, + Sha256, + TwoCap, + >; + + fn variable_config( + suffix: &str, + pooler: &(impl BufferPooler + Metrics), + ) -> immutable::variable::Config { + const ITEMS_PER_SECTION: NonZeroU64 = NZU64!(5); + + let page_cache = + CacheRef::from_pooler(&pooler.with_label("page_cache"), PAGE_SIZE, PAGE_CACHE_SIZE); + immutable::Config { + merkle_config: MerkleConfig { + journal_partition: format!("journal-{suffix}"), + metadata_partition: format!("metadata-{suffix}"), + items_per_blob: NZU64!(11), + write_buffer: NZUsize!(1024), + thread_pool: None, + page_cache: page_cache.clone(), + }, + log: crate::journal::contiguous::variable::Config { + partition: format!("log-{suffix}"), + items_per_section: ITEMS_PER_SECTION, + compression: None, + codec_config: ((), ()), + page_cache, + write_buffer: NZUsize!(1024), + }, + translator: TwoCap, + } + } + + fn variable_create_ops_seeded( + n: usize, + seed: u64, + ) -> Vec> { + let mut rng = test_rng_seeded(seed); + let mut ops = Vec::new(); + for _ in 0..n { + let key = sha256::Digest::random(&mut rng); + let value = sha256::Digest::random(&mut rng); + ops.push(Operation::Set(key, value)); + } + ops + } + + async fn variable_apply_ops( + mut db: VariableDb, + ops: Vec>, + metadata: Option, + ) -> VariableDb + where + VariableDb: qmdb::sync::Database, + { + let mut batch = db.new_batch(); + for op in ops { + match op { + Operation::Set(key, value) => { + batch = batch.set(key, value); + } + Operation::Commit(_) => { + panic!("Commit operation not supported in apply_ops"); + } + } + } + let merkleized = batch.merkleize(&db, metadata); + db.apply_batch(merkleized).await.unwrap(); + db + } + + pub(crate) struct VariableHarness(std::marker::PhantomData); + + impl SyncTestHarness for VariableHarness { + type Family = F; + type Db = VariableDb; + type Key = sha256::Digest; + type Value = sha256::Digest; + type Metadata = sha256::Digest; + + fn config(suffix: &str, pooler: &(impl BufferPooler + Metrics)) -> ConfigOf { + variable_config(suffix, pooler) + } + + fn create_ops(n: usize) -> Vec> { + variable_create_ops_seeded(n, 0) + } + + fn create_ops_seeded(n: usize, seed: u64) -> Vec> { + variable_create_ops_seeded(n, seed) + } + + fn sample_metadata() -> Self::Metadata { + Sha256::fill(1) + } + + async fn init_db(mut ctx: deterministic::Context) -> Self::Db { + let seed = ctx.next_u64(); + let config = variable_config(&format!("sync-test-{seed}"), &ctx); + Self::Db::init(ctx, config).await.unwrap() + } + + async fn init_db_with_config( + ctx: deterministic::Context, + config: ConfigOf, + ) -> Self::Db { + Self::Db::init(ctx, config).await.unwrap() + } + + async fn destroy(db: Self::Db) { + db.destroy().await.unwrap(); + } + + async fn db_sync(db: &Self::Db) { + db.sync().await.unwrap(); + } + + async fn apply_ops( + db: Self::Db, + ops: Vec>, + metadata: Option, + ) -> Self::Db { + variable_apply_ops::(db, ops, metadata).await + } + + async fn prune(db: &mut Self::Db, loc: Location) { + db.prune(loc).await.unwrap(); + } + + async fn bounds(db: &Self::Db) -> std::ops::Range> { + db.bounds().await + } + + fn db_root(db: &Self::Db) -> sha256::Digest { + db.root() + } + + async fn get_metadata(db: &Self::Db) -> Option { + db.get_metadata().await.unwrap() + } + + fn op_kv(op: &OpOf) -> Option<(&Self::Key, &Self::Value)> { + match op { + Operation::Set(key, value) => Some((key, value)), + Operation::Commit(_) => None, + } + } + + async fn lookup(db: &Self::Db, key: &Self::Key) -> Option { + db.get(key).await.unwrap() + } + } + + pub(crate) type VariableMmrHarness = VariableHarness; + pub(crate) type VariableMmbHarness = VariableHarness; +} + +// ===== Test Generation Macro ===== + +macro_rules! sync_tests_for_harness { + ($harness:ty, $mod_name:ident) => { + mod $mod_name { + use super::harnesses; + use commonware_macros::test_traced; + use rstest::rstest; + use std::num::NonZeroU64; + + #[rstest] + #[case::singleton_batch_size_one(1, 1)] + #[case::singleton_batch_size_gt_db_size(1, 2)] + #[case::batch_size_one(1000, 1)] + #[case::floor_div_db_batch_size(1000, 3)] + #[case::floor_div_db_batch_size_2(1000, 999)] + #[case::div_db_batch_size(1000, 100)] + #[case::db_size_eq_batch_size(1000, 1000)] + #[case::batch_size_gt_db_size(1000, 1001)] + fn test_sync(#[case] target_db_ops: usize, #[case] fetch_batch_size: u64) { + super::test_sync::<$harness>( + target_db_ops, + NonZeroU64::new(fetch_batch_size).unwrap(), + ); + } + + #[test_traced("WARN")] + fn test_sync_empty_to_nonempty() { + super::test_sync_empty_to_nonempty::<$harness>(); + } + + #[test_traced("WARN")] + fn test_sync_database_persistence() { + super::test_sync_database_persistence::<$harness>(); + } + + #[test_traced("WARN")] + fn test_target_update_during_sync() { + super::test_target_update_during_sync::<$harness>(); + } + + #[test] + fn test_sync_subset_of_target_database() { + super::test_sync_subset_of_target_database::<$harness>(); + } + + #[test] + fn test_sync_use_existing_db_partial_match() { + super::test_sync_use_existing_db_partial_match::<$harness>(); + } + + #[test] + fn test_sync_use_existing_db_exact_match() { + super::test_sync_use_existing_db_exact_match::<$harness>(); + } + + #[test_traced("WARN")] + fn test_target_update_lower_bound_decrease() { + super::test_target_update_lower_bound_decrease::<$harness>(); + } + + #[test_traced("WARN")] + fn test_target_update_upper_bound_decrease() { + super::test_target_update_upper_bound_decrease::<$harness>(); + } + + #[test_traced("WARN")] + fn test_target_update_bounds_increase() { + super::test_target_update_bounds_increase::<$harness>(); + } + + #[test_traced("WARN")] + fn test_target_update_on_done_client() { + super::test_target_update_on_done_client::<$harness>(); + } + } + }; +} + +sync_tests_for_harness!(harnesses::VariableMmrHarness, variable_mmr); +sync_tests_for_harness!(harnesses::VariableMmbHarness, variable_mmb); diff --git a/storage/src/qmdb/keyless/fixed.rs b/storage/src/qmdb/keyless/fixed.rs index d0fb9f2f90d..02435c456ea 100644 --- a/storage/src/qmdb/keyless/fixed.rs +++ b/storage/src/qmdb/keyless/fixed.rs @@ -572,4 +572,72 @@ mod test { tests::test_keyless_db_rewind_pruned_target_errors(db).await; }); } + + /// Smoke test: verify the sync engine works end-to-end with a fixed-size keyless database. + /// The full sync test suite runs against the variable variant via the harness in + /// [`super::super::sync::tests`]; this test covers the fixed-size code path. + #[test_traced("WARN")] + fn test_keyless_fixed_sync() { + use crate::{ + merkle::Location, + qmdb::sync::{self, engine::Config, Target}, + }; + use commonware_utils::{non_empty_range, sequence::U64}; + use std::sync::Arc; + + deterministic::Runner::default().start(|ctx| async move { + let target_config = db_config("sync-target", &ctx); + let mut target_db: TestDb = + TestDb::init(ctx.with_label("target"), target_config) + .await + .unwrap(); + + let mut batch = target_db.new_batch(); + for i in 0..20u64 { + batch = batch.append(U64::new(i * 10 + 1)); + } + let merkleized = batch.merkleize(&target_db, None); + target_db.apply_batch(merkleized).await.unwrap(); + + let target_root = target_db.root(); + let bounds = target_db.bounds().await; + let lower_bound = bounds.start; + let upper_bound = bounds.end; + + let client_config = db_config("sync-client", &ctx); + let target_db = Arc::new(target_db); + let config = Config { + db_config: client_config, + fetch_batch_size: NZU64!(5), + target: Target { + root: target_root, + range: non_empty_range!(lower_bound, upper_bound), + }, + context: ctx.with_label("client"), + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let synced_db: TestDb = sync::sync(config).await.unwrap(); + + assert_eq!(synced_db.root(), target_root); + let bounds = synced_db.bounds().await; + assert_eq!(bounds.end, upper_bound); + assert_eq!(bounds.start, lower_bound); + + for i in 0..20u64 { + let got = synced_db.get(Location::new(i + 1)).await.unwrap(); + assert_eq!(got, Some(U64::new(i * 10 + 1))); + } + + synced_db.destroy().await.unwrap(); + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); + target_db.destroy().await.unwrap(); + }); + } } diff --git a/storage/src/qmdb/keyless/sync.rs b/storage/src/qmdb/keyless/sync.rs deleted file mode 100644 index 78d7bae8f9c..00000000000 --- a/storage/src/qmdb/keyless/sync.rs +++ /dev/null @@ -1,1029 +0,0 @@ -use crate::{ - journal::{ - authenticated, - contiguous::{Contiguous as _, Mutable, Reader as _}, - Error as JournalError, - }, - merkle::{ - hasher::Standard as StandardHasher, - journaled::{self, Journaled}, - Family, Location, - }, - qmdb::{ - self, - any::value::ValueEncoding, - keyless::{operation::Codec, Keyless, Operation}, - operation::Committable as _, - sync, - }, - Context, Persistable, -}; -use commonware_codec::EncodeShared; -use commonware_cryptography::Hasher; -use commonware_utils::range::NonEmptyRange; - -impl sync::Database for Keyless -where - F: Family, - E: Context, - V: ValueEncoding + Codec, - C: Mutable> - + Persistable - + sync::Journal>, - C::Config: Clone + Send, - H: Hasher, - Operation: EncodeShared, -{ - type Family = F; - type Op = Operation; - type Journal = C; - type Hasher = H; - type Config = super::Config; - type Digest = H::Digest; - type Context = E; - - /// Returns a [Keyless] db initialized from data collected in the sync process. - /// - /// # Behavior - /// - /// This method handles different initialization scenarios based on existing data: - /// - If the Merkle journal is empty or the last item is before the range start, it creates - /// a fresh Merkle structure from the provided `pinned_nodes` - /// - If the Merkle journal has data but is incomplete (has length < range end), missing - /// operations from the log are applied to bring it up to the target state - /// - If the Merkle journal has data beyond the range end, it is rewound to match the sync - /// target - /// - /// # Returns - /// - /// A [Keyless] db populated with the state from the given range. - async fn from_sync_result( - context: Self::Context, - config: Self::Config, - log: Self::Journal, - pinned_nodes: Option>, - range: NonEmptyRange>, - apply_batch_size: usize, - ) -> Result> { - let hasher = StandardHasher::::new(); - - let merkle = Journaled::init_sync( - context.with_label("merkle"), - journaled::SyncConfig { - config: config.merkle.clone(), - range, - pinned_nodes, - }, - &hasher, - ) - .await?; - - let journal = authenticated::Journal::::from_components( - merkle, - log, - hasher, - apply_batch_size as u64, - ) - .await?; - - let last_commit_loc = { - let reader = journal.reader().await; - let loc = reader - .bounds() - .end - .checked_sub(1) - .expect("journal should not be empty"); - let op = reader.read(loc).await?; - assert!(op.is_commit(), "last operation should be a commit"); - Location::new(loc) - }; - - let db = Self { - journal, - last_commit_loc, - }; - - db.sync().await?; - Ok(db) - } - - fn root(&self) -> Self::Digest { - self.root() - } -} - -#[cfg(test)] -mod tests { - use crate::{ - journal::contiguous::Contiguous, - merkle::mmr::{self, Location}, - qmdb::{ - keyless::{self, variable, Operation}, - sync::{ - self, - engine::{Config, NextStep}, - resolver::tests::FailResolver, - Engine, Target, - }, - }, - }; - use commonware_cryptography::{sha256, Sha256}; - use commonware_macros::test_traced; - use commonware_runtime::{ - buffer::paged::CacheRef, deterministic, BufferPooler, Metrics, Runner as _, - }; - use commonware_utils::{ - channel::mpsc, non_empty_range, test_rng_seeded, NZUsize, NZU16, NZU64, - }; - use rand::RngCore as _; - use rstest::rstest; - use std::{ - num::{NonZeroU16, NonZeroU64, NonZeroUsize}, - sync::Arc, - }; - - /// Type alias for sync tests with variable-length values. - type KeylessSyncTest = variable::Db, Sha256>; - - type VariableOp = Operation>>; - - // Used by both `create_sync_config` and `test_sync_fixed`. - const PAGE_SIZE: NonZeroU16 = NZU16!(77); - const PAGE_CACHE_SIZE: NonZeroUsize = NZUsize!(9); - - /// Create a simple config for sync tests. - fn create_sync_config( - suffix: &str, - pooler: &(impl BufferPooler + commonware_runtime::Metrics), - ) -> variable::Config<(commonware_codec::RangeCfg, ())> { - const ITEMS_PER_SECTION: NonZeroU64 = NZU64!(5); - - let page_cache = - CacheRef::from_pooler(&pooler.with_label("page_cache"), PAGE_SIZE, PAGE_CACHE_SIZE); - keyless::Config { - merkle: crate::merkle::journaled::Config { - journal_partition: format!("journal-{suffix}"), - metadata_partition: format!("metadata-{suffix}"), - items_per_blob: NZU64!(11), - write_buffer: NZUsize!(1024), - thread_pool: None, - page_cache: page_cache.clone(), - }, - log: crate::journal::contiguous::variable::Config { - partition: format!("log-{suffix}"), - items_per_section: ITEMS_PER_SECTION, - compression: None, - codec_config: ((0..=10000).into(), ()), - page_cache, - write_buffer: NZUsize!(1024), - }, - } - } - - /// Create a test database with unique partition names. - async fn create_test_db(mut context: deterministic::Context) -> KeylessSyncTest { - let seed = context.next_u64(); - let config = create_sync_config(&format!("sync-test-{seed}"), &context); - KeylessSyncTest::init(context, config).await.unwrap() - } - - /// Create n random Append operations using the default seed (0). - /// create_test_ops(n) is a prefix of create_test_ops(n') for n < n'. - fn create_test_ops(n: usize) -> Vec { - create_test_ops_seeded(n, 0) - } - - /// Create n random Append operations using a specific seed. - /// Use different seeds when you need non-overlapping values in the same test. - fn create_test_ops_seeded(n: usize, seed: u64) -> Vec { - let mut rng = test_rng_seeded(seed); - let mut ops = Vec::with_capacity(n); - for _ in 0..n { - let len = (rng.next_u32() % 100 + 1) as usize; - let mut value = vec![0u8; len]; - rng.fill_bytes(&mut value); - ops.push(Operation::Append(value)); - } - ops - } - - /// Applies the given operations and commits the database. - async fn apply_ops(db: &mut KeylessSyncTest, ops: Vec, metadata: Option>) { - let mut batch = db.new_batch(); - for op in ops { - match op { - Operation::Append(value) => { - batch = batch.append(value); - } - Operation::Commit(_) => { - panic!("Commit operation not supported in apply_ops"); - } - } - } - let merkleized = batch.merkleize(db, metadata); - db.apply_batch(merkleized).await.unwrap(); - } - - /// Test that resolver failure is handled correctly. - #[test_traced("WARN")] - fn test_sync_resolver_fails() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let resolver = FailResolver::::new(); - let db_config = create_sync_config(&context.next_u64().to_string(), &context); - let config = Config { - context: context.with_label("client"), - target: Target { - root: sha256::Digest::from([0; 32]), - range: non_empty_range!(Location::new(0), Location::new(5)), - }, - resolver, - apply_batch_size: 2, - max_outstanding_requests: 2, - fetch_batch_size: NZU64!(2), - db_config, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - - let result: Result = sync::sync(config).await; - assert!(result.is_err()); - }); - } - - #[rstest] - #[case::singleton_batch_size_one(1, NZU64!(1))] - #[case::singleton_batch_size_gt_db_size(1, NZU64!(2))] - #[case::batch_size_one(1000, NZU64!(1))] - #[case::floor_div_db_batch_size(1000, NZU64!(3))] - #[case::floor_div_db_batch_size_2(1000, NZU64!(999))] - #[case::div_db_batch_size(1000, NZU64!(100))] - #[case::db_size_eq_batch_size(1000, NZU64!(1000))] - #[case::batch_size_gt_db_size(1000, NZU64!(1001))] - fn test_sync(#[case] target_db_ops: usize, #[case] fetch_batch_size: NonZeroU64) { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(target_db_ops); - apply_ops(&mut target_db, target_ops.clone(), Some(vec![42])).await; - let bounds = target_db.bounds().await; - let target_op_count = bounds.end; - let target_oldest_retained_loc = bounds.start; - let target_root = target_db.root(); - - let db_config = - create_sync_config(&format!("sync_client_{}", context.next_u64()), &context); - - let target_db = Arc::new(target_db); - let config = Config { - db_config: db_config.clone(), - fetch_batch_size, - target: Target { - root: target_root, - range: non_empty_range!(target_oldest_retained_loc, target_op_count), - }, - context: context.with_label("client"), - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let got_db: KeylessSyncTest = sync::sync(config).await.unwrap(); - - // Verify database state - let bounds = got_db.bounds().await; - assert_eq!(bounds.end, target_op_count); - assert_eq!(bounds.start, target_oldest_retained_loc); - assert_eq!(got_db.root(), target_root); - - // Verify values match - for (i, op) in target_ops.iter().enumerate() { - if let Operation::Append(value) = op { - // +1 because location 0 is the initial commit - let got = got_db.get(Location::new(i as u64 + 1)).await.unwrap(); - assert_eq!(got.as_ref(), Some(value)); - } - } - - // Apply more operations to both databases and verify they remain consistent - let new_ops = create_test_ops_seeded(target_db_ops, 1); - let mut got_db = got_db; - apply_ops(&mut got_db, new_ops.clone(), None).await; - let mut target_db = Arc::try_unwrap(target_db) - .unwrap_or_else(|_| panic!("target_db should have no other references")); - apply_ops(&mut target_db, new_ops, None).await; - - assert_eq!(got_db.root(), target_db.root()); - - got_db.destroy().await.unwrap(); - target_db.destroy().await.unwrap(); - }); - } - - #[test_traced("WARN")] - fn test_sync_empty_to_nonempty() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let mut target_db = create_test_db(context.with_label("target")).await; - apply_ops(&mut target_db, vec![], Some(vec![1, 2, 3])).await; - - let bounds = target_db.bounds().await; - let target_op_count = bounds.end; - let target_oldest_retained_loc = bounds.start; - let target_root = target_db.root(); - - let db_config = - create_sync_config(&format!("empty_sync_{}", context.next_u64()), &context); - let target_db = Arc::new(target_db); - let config = Config { - db_config, - fetch_batch_size: NZU64!(10), - target: Target { - root: target_root, - range: non_empty_range!(target_oldest_retained_loc, target_op_count), - }, - context: context.with_label("client"), - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let got_db: KeylessSyncTest = sync::sync(config).await.unwrap(); - - let bounds = got_db.bounds().await; - assert_eq!(bounds.end, target_op_count); - assert_eq!(bounds.start, target_oldest_retained_loc); - assert_eq!(got_db.root(), target_root); - assert_eq!(got_db.get_metadata().await.unwrap(), Some(vec![1, 2, 3])); - - got_db.destroy().await.unwrap(); - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - #[test_traced("WARN")] - fn test_sync_database_persistence() { - let executor = deterministic::Runner::default(); - executor.start(|context| async move { - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(10); - apply_ops(&mut target_db, target_ops.clone(), Some(vec![0])).await; - - let target_root = target_db.root(); - let bounds = target_db.bounds().await; - let lower_bound = bounds.start; - let op_count = bounds.end; - - let db_config = create_sync_config("persistence-test", &context); - let client_context = context.with_label("client"); - let target_db = Arc::new(target_db); - let config = Config { - db_config: db_config.clone(), - fetch_batch_size: NZU64!(5), - target: Target { - root: target_root, - range: non_empty_range!(lower_bound, op_count), - }, - context: client_context.clone(), - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let synced_db: KeylessSyncTest = sync::sync(config).await.unwrap(); - - assert_eq!(synced_db.root(), target_root); - let expected_root = synced_db.root(); - let bounds = synced_db.bounds().await; - let expected_op_count = bounds.end; - let expected_oldest_retained_loc = bounds.start; - - // Drop and reopen - synced_db.sync().await.unwrap(); - drop(synced_db); - let reopened_db = KeylessSyncTest::init(context.with_label("reopened"), db_config) - .await - .unwrap(); - - assert_eq!(reopened_db.root(), expected_root); - let bounds = reopened_db.bounds().await; - assert_eq!(bounds.end, expected_op_count); - assert_eq!(bounds.start, expected_oldest_retained_loc); - - // Verify data integrity - for (i, op) in target_ops.iter().enumerate() { - if let Operation::Append(value) = op { - let got = reopened_db.get(Location::new(i as u64 + 1)).await.unwrap(); - assert_eq!(got.as_ref(), Some(value)); - } - } - - reopened_db.destroy().await.unwrap(); - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - #[test_traced("WARN")] - fn test_target_update_during_sync() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let mut target_db = create_test_db(context.with_label("target")).await; - let initial_ops = create_test_ops(50); - apply_ops(&mut target_db, initial_ops, None).await; - - let bounds = target_db.bounds().await; - let initial_lower_bound = bounds.start; - let initial_upper_bound = bounds.end; - let initial_root = target_db.root(); - - let additional_ops = create_test_ops_seeded(25, 1); - apply_ops(&mut target_db, additional_ops, None).await; - let final_upper_bound = target_db.bounds().await.end; - let final_root = target_db.root(); - - let target_db = Arc::new(target_db); - - let (update_sender, update_receiver) = mpsc::channel(1); - let client = { - let config = Config { - context: context.with_label("client"), - db_config: create_sync_config( - &format!("update_test_{}", context.next_u64()), - &context, - ), - target: Target { - root: initial_root, - range: non_empty_range!(initial_lower_bound, initial_upper_bound), - }, - resolver: target_db.clone(), - fetch_batch_size: NZU64!(2), - max_outstanding_requests: 10, - apply_batch_size: 1024, - update_rx: Some(update_receiver), - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 1, - }; - let mut client: Engine = Engine::new(config).await.unwrap(); - loop { - client = match client.step().await.unwrap() { - NextStep::Continue(new_client) => new_client, - NextStep::Complete(_) => panic!("client should not be complete"), - }; - let log_size = Contiguous::size(client.journal()).await; - if log_size > initial_lower_bound { - break client; - } - } - }; - - update_sender - .send(Target { - root: final_root, - range: non_empty_range!(initial_lower_bound, final_upper_bound), - }) - .await - .unwrap(); - - let synced_db = client.sync().await.unwrap(); - assert_eq!(synced_db.root(), final_root); - - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); - { - let bounds = synced_db.bounds().await; - let target_bounds = target_db.bounds().await; - assert_eq!(bounds.end, target_bounds.end); - assert_eq!(bounds.start, target_bounds.start); - assert_eq!(synced_db.root(), target_db.root()); - } - - synced_db.destroy().await.unwrap(); - target_db.destroy().await.unwrap(); - }); - } - - #[test] - fn test_sync_subset_of_target_database() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(30); - // Apply all but the last operation - apply_ops(&mut target_db, target_ops[..29].to_vec(), None).await; - - let target_root = target_db.root(); - let bounds = target_db.bounds().await; - let lower_bound = bounds.start; - let op_count = bounds.end; - - // Add final op after capturing the range - apply_ops(&mut target_db, target_ops[29..].to_vec(), None).await; - - let target_db = Arc::new(target_db); - let config = Config { - db_config: create_sync_config(&format!("subset_{}", context.next_u64()), &context), - fetch_batch_size: NZU64!(10), - target: Target { - root: target_root, - range: non_empty_range!(lower_bound, op_count), - }, - context: context.with_label("client"), - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let synced_db: KeylessSyncTest = sync::sync(config).await.unwrap(); - - assert_eq!(synced_db.root(), target_root); - assert_eq!(synced_db.bounds().await.end, op_count); - - synced_db.destroy().await.unwrap(); - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - #[test] - fn test_sync_use_existing_db_partial_match() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let original_ops = create_test_ops(50); - - let mut target_db = create_test_db(context.with_label("target")).await; - let sync_db_config = - create_sync_config(&format!("partial_{}", context.next_u64()), &context); - let client_context = context.with_label("client"); - let mut sync_db: KeylessSyncTest = - KeylessSyncTest::init(client_context.clone(), sync_db_config.clone()) - .await - .unwrap(); - - // Apply same operations to both - apply_ops(&mut target_db, original_ops.clone(), None).await; - apply_ops(&mut sync_db, original_ops, None).await; - - drop(sync_db); - - // Add one more op to target - let last_op = create_test_ops_seeded(1, 1); - apply_ops(&mut target_db, last_op, None).await; - let root = target_db.root(); - let bounds = target_db.bounds().await; - let lower_bound = bounds.start; - let upper_bound = bounds.end; - - let target_db = Arc::new(target_db); - let config = Config { - db_config: sync_db_config, - fetch_batch_size: NZU64!(10), - target: Target { - root, - range: non_empty_range!(lower_bound, upper_bound), - }, - context: context.with_label("sync"), - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let sync_db: KeylessSyncTest = sync::sync(config).await.unwrap(); - - assert_eq!(sync_db.bounds().await.end, upper_bound); - assert_eq!(sync_db.root(), root); - - sync_db.destroy().await.unwrap(); - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - #[test] - fn test_sync_use_existing_db_exact_match() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let target_ops = create_test_ops(40); - - let mut target_db = create_test_db(context.with_label("target")).await; - let sync_config = - create_sync_config(&format!("exact_{}", context.next_u64()), &context); - let client_context = context.with_label("client"); - let mut sync_db: KeylessSyncTest = - KeylessSyncTest::init(client_context.clone(), sync_config.clone()) - .await - .unwrap(); - - apply_ops(&mut target_db, target_ops.clone(), None).await; - apply_ops(&mut sync_db, target_ops, None).await; - - drop(sync_db); - - let root = target_db.root(); - let bounds = target_db.bounds().await; - let lower_bound = bounds.start; - let upper_bound = bounds.end; - - let resolver = Arc::new(target_db); - let config = Config { - db_config: sync_config, - fetch_batch_size: NZU64!(10), - target: Target { - root, - range: non_empty_range!(lower_bound, upper_bound), - }, - context: context.with_label("sync"), - resolver: resolver.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let sync_db: KeylessSyncTest = sync::sync(config).await.unwrap(); - - assert_eq!(sync_db.bounds().await.end, upper_bound); - assert_eq!(sync_db.root(), root); - - sync_db.destroy().await.unwrap(); - let target_db = - Arc::try_unwrap(resolver).unwrap_or_else(|_| panic!("failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - #[test_traced("WARN")] - fn test_target_update_lower_bound_decrease() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(100); - apply_ops(&mut target_db, target_ops, None).await; - - target_db.prune(Location::new(10)).await.unwrap(); - - let bounds = target_db.bounds().await; - let initial_lower_bound = bounds.start; - let initial_upper_bound = bounds.end; - let initial_root = target_db.root(); - - let (update_sender, update_receiver) = mpsc::channel(1); - let target_db = Arc::new(target_db); - let config = Config { - context: context.with_label("client"), - db_config: create_sync_config(&format!("lb-dec-{}", context.next_u64()), &context), - fetch_batch_size: NZU64!(5), - target: Target { - root: initial_root, - range: non_empty_range!(initial_lower_bound, initial_upper_bound), - }, - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 10, - update_rx: Some(update_receiver), - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 1, - }; - let client: Engine = Engine::new(config).await.unwrap(); - - update_sender - .send(Target { - root: initial_root, - range: non_empty_range!( - initial_lower_bound.checked_sub(1).unwrap(), - initial_upper_bound - ), - }) - .await - .unwrap(); - - let result = client.step().await; - assert!(matches!( - result, - Err(sync::Error::Engine( - sync::EngineError::SyncTargetMovedBackward { .. } - )) - )); - - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - #[test_traced("WARN")] - fn test_target_update_upper_bound_decrease() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(50); - apply_ops(&mut target_db, target_ops, None).await; - - let bounds = target_db.bounds().await; - let initial_lower_bound = bounds.start; - let initial_upper_bound = bounds.end; - let initial_root = target_db.root(); - - let (update_sender, update_receiver) = mpsc::channel(1); - let target_db = Arc::new(target_db); - let config = Config { - context: context.with_label("client"), - db_config: create_sync_config(&format!("ub-dec-{}", context.next_u64()), &context), - fetch_batch_size: NZU64!(5), - target: Target { - root: initial_root, - range: non_empty_range!(initial_lower_bound, initial_upper_bound), - }, - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 10, - update_rx: Some(update_receiver), - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 1, - }; - let client: Engine = Engine::new(config).await.unwrap(); - - update_sender - .send(Target { - root: initial_root, - range: non_empty_range!(initial_lower_bound, initial_upper_bound - 1), - }) - .await - .unwrap(); - - let result = client.step().await; - assert!(matches!( - result, - Err(sync::Error::Engine( - sync::EngineError::SyncTargetMovedBackward { .. } - )) - )); - - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - #[test_traced("WARN")] - fn test_target_update_bounds_increase() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(100); - apply_ops(&mut target_db, target_ops, None).await; - - let bounds = target_db.bounds().await; - let initial_lower_bound = bounds.start; - let initial_upper_bound = bounds.end; - let initial_root = target_db.root(); - - let more_ops = create_test_ops_seeded(5, 1); - apply_ops(&mut target_db, more_ops, None).await; - - target_db.prune(Location::new(10)).await.unwrap(); - apply_ops(&mut target_db, vec![], None).await; - - let bounds = target_db.bounds().await; - let final_lower_bound = bounds.start; - let final_upper_bound = bounds.end; - let final_root = target_db.root(); - - assert_ne!(final_lower_bound, initial_lower_bound); - assert_ne!(final_upper_bound, initial_upper_bound); - - let (update_sender, update_receiver) = mpsc::channel(1); - let target_db = Arc::new(target_db); - let config = Config { - context: context.with_label("client"), - db_config: create_sync_config( - &format!("bounds_inc_{}", context.next_u64()), - &context, - ), - fetch_batch_size: NZU64!(1), - target: Target { - root: initial_root, - range: non_empty_range!(initial_lower_bound, initial_upper_bound), - }, - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: Some(update_receiver), - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 1, - }; - - update_sender - .send(Target { - root: final_root, - range: non_empty_range!(final_lower_bound, final_upper_bound), - }) - .await - .unwrap(); - - let synced_db: KeylessSyncTest = sync::sync(config).await.unwrap(); - - assert_eq!(synced_db.root(), final_root); - let bounds = synced_db.bounds().await; - assert_eq!(bounds.end, final_upper_bound); - assert_eq!(bounds.start, final_lower_bound); - - synced_db.destroy().await.unwrap(); - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } - - #[test_traced("WARN")] - fn test_target_update_on_done_client() { - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let mut target_db = create_test_db(context.with_label("target")).await; - let target_ops = create_test_ops(10); - apply_ops(&mut target_db, target_ops, None).await; - - let bounds = target_db.bounds().await; - let lower_bound = bounds.start; - let upper_bound = bounds.end; - let root = target_db.root(); - - let (update_sender, update_receiver) = mpsc::channel(1); - let target_db = Arc::new(target_db); - let config = Config { - context: context.with_label("client"), - db_config: create_sync_config(&format!("done_{}", context.next_u64()), &context), - fetch_batch_size: NZU64!(20), - target: Target { - root, - range: non_empty_range!(lower_bound, upper_bound), - }, - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 10, - update_rx: Some(update_receiver), - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 1, - }; - - let synced_db: KeylessSyncTest = sync::sync(config).await.unwrap(); - - let _ = update_sender - .send(Target { - root: sha256::Digest::from([2u8; 32]), - range: non_empty_range!(lower_bound + 1, upper_bound + 1), - }) - .await; - - assert_eq!(synced_db.root(), root); - let bounds = synced_db.bounds().await; - assert_eq!(bounds.end, upper_bound); - assert_eq!(bounds.start, lower_bound); - - synced_db.destroy().await.unwrap(); - Arc::try_unwrap(target_db) - .unwrap_or_else(|_| panic!("failed to unwrap Arc")) - .destroy() - .await - .unwrap(); - }); - } - - // Extra test verifying the generic sync::Database impl works for fixed-size journals. - // Not present in immutable (which only tests variable). - #[test_traced("WARN")] - fn test_sync_fixed() { - use crate::qmdb::keyless::fixed; - use commonware_utils::sequence::U64; - - type FixedSyncTest = fixed::Db; - - let executor = deterministic::Runner::default(); - executor.start(|mut context| async move { - let page_cache = CacheRef::from_pooler( - &context.with_label("page_cache"), - PAGE_SIZE, - PAGE_CACHE_SIZE, - ); - let target_config = fixed::Config { - merkle: crate::merkle::journaled::Config { - journal_partition: format!("fixed-journal-target-{}", context.next_u64()), - metadata_partition: format!("fixed-metadata-target-{}", context.next_u64()), - items_per_blob: NZU64!(11), - write_buffer: NZUsize!(1024), - thread_pool: None, - page_cache: page_cache.clone(), - }, - log: crate::journal::contiguous::fixed::Config { - partition: format!("fixed-log-target-{}", context.next_u64()), - items_per_blob: NZU64!(7), - page_cache: page_cache.clone(), - write_buffer: NZUsize!(1024), - }, - }; - - let mut target_db: FixedSyncTest = - FixedSyncTest::init(context.with_label("target"), target_config) - .await - .unwrap(); - - // Add some values - let mut batch = target_db.new_batch(); - for i in 0..20u64 { - batch = batch.append(U64::new(i * 10 + 1)); - } - let merkleized = batch.merkleize(&target_db, None); - target_db.apply_batch(merkleized).await.unwrap(); - - let target_root = target_db.root(); - let bounds = target_db.bounds().await; - let lower_bound = bounds.start; - let upper_bound = bounds.end; - - let client_page_cache = CacheRef::from_pooler( - &context.with_label("client_page_cache"), - PAGE_SIZE, - PAGE_CACHE_SIZE, - ); - let client_config = fixed::Config { - merkle: crate::merkle::journaled::Config { - journal_partition: format!("fixed-journal-client-{}", context.next_u64()), - metadata_partition: format!("fixed-metadata-client-{}", context.next_u64()), - items_per_blob: NZU64!(11), - write_buffer: NZUsize!(1024), - thread_pool: None, - page_cache: client_page_cache.clone(), - }, - log: crate::journal::contiguous::fixed::Config { - partition: format!("fixed-log-client-{}", context.next_u64()), - items_per_blob: NZU64!(7), - page_cache: client_page_cache, - write_buffer: NZUsize!(1024), - }, - }; - - let target_db = Arc::new(target_db); - let config = Config { - db_config: client_config, - fetch_batch_size: NZU64!(5), - target: Target { - root: target_root, - range: non_empty_range!(lower_bound, upper_bound), - }, - context: context.with_label("client"), - resolver: target_db.clone(), - apply_batch_size: 1024, - max_outstanding_requests: 1, - update_rx: None, - finish_rx: None, - reached_target_tx: None, - max_retained_roots: 8, - }; - let synced_db: FixedSyncTest = sync::sync(config).await.unwrap(); - - assert_eq!(synced_db.root(), target_root); - let bounds = synced_db.bounds().await; - assert_eq!(bounds.end, upper_bound); - assert_eq!(bounds.start, lower_bound); - - // Verify values - for i in 0..20u64 { - let got = synced_db.get(Location::new(i + 1)).await.unwrap(); - assert_eq!(got, Some(U64::new(i * 10 + 1))); - } - - synced_db.destroy().await.unwrap(); - let target_db = - Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); - target_db.destroy().await.unwrap(); - }); - } -} diff --git a/storage/src/qmdb/keyless/sync/mod.rs b/storage/src/qmdb/keyless/sync/mod.rs new file mode 100644 index 00000000000..757790ec549 --- /dev/null +++ b/storage/src/qmdb/keyless/sync/mod.rs @@ -0,0 +1,116 @@ +use crate::{ + journal::{ + authenticated, + contiguous::{Contiguous as _, Mutable, Reader as _}, + Error as JournalError, + }, + merkle::{ + hasher::Standard as StandardHasher, + journaled::{self, Journaled}, + Family, Location, + }, + qmdb::{ + self, + any::value::ValueEncoding, + keyless::{operation::Codec, Keyless, Operation}, + operation::Committable as _, + sync, + }, + Context, Persistable, +}; +use commonware_codec::EncodeShared; +use commonware_cryptography::Hasher; +use commonware_utils::range::NonEmptyRange; + +impl sync::Database for Keyless +where + F: Family, + E: Context, + V: ValueEncoding + Codec, + C: Mutable> + + Persistable + + sync::Journal>, + C::Config: Clone + Send, + H: Hasher, + Operation: EncodeShared, +{ + type Family = F; + type Op = Operation; + type Journal = C; + type Hasher = H; + type Config = super::Config; + type Digest = H::Digest; + type Context = E; + + /// Returns a [Keyless] db initialized from data collected in the sync process. + /// + /// # Behavior + /// + /// This method handles different initialization scenarios based on existing data: + /// - If the Merkle journal is empty or the last item is before the range start, it creates + /// a fresh Merkle structure from the provided `pinned_nodes` + /// - If the Merkle journal has data but is incomplete (has length < range end), missing + /// operations from the log are applied to bring it up to the target state + /// - If the Merkle journal has data beyond the range end, it is rewound to match the sync + /// target + /// + /// # Returns + /// + /// A [Keyless] db populated with the state from the given range. + async fn from_sync_result( + context: Self::Context, + config: Self::Config, + log: Self::Journal, + pinned_nodes: Option>, + range: NonEmptyRange>, + apply_batch_size: usize, + ) -> Result> { + let hasher = StandardHasher::::new(); + + let merkle = Journaled::init_sync( + context.with_label("merkle"), + journaled::SyncConfig { + config: config.merkle.clone(), + range, + pinned_nodes, + }, + &hasher, + ) + .await?; + + let journal = authenticated::Journal::::from_components( + merkle, + log, + hasher, + apply_batch_size as u64, + ) + .await?; + + let last_commit_loc = { + let reader = journal.reader().await; + let loc = reader + .bounds() + .end + .checked_sub(1) + .expect("journal should not be empty"); + let op = reader.read(loc).await?; + assert!(op.is_commit(), "last operation should be a commit"); + Location::new(loc) + }; + + let db = Self { + journal, + last_commit_loc, + }; + + db.sync().await?; + Ok(db) + } + + fn root(&self) -> Self::Digest { + self.root() + } +} + +#[cfg(test)] +mod tests; diff --git a/storage/src/qmdb/keyless/sync/tests.rs b/storage/src/qmdb/keyless/sync/tests.rs new file mode 100644 index 00000000000..69df2e9c33a --- /dev/null +++ b/storage/src/qmdb/keyless/sync/tests.rs @@ -0,0 +1,1018 @@ +//! Generic sync tests for keyless databases. +//! +//! This module defines a [`SyncTestHarness`] trait and generic test functions parameterized +//! over the harness, so the same tests can run against any combination of merkle family +//! (MMR, MMB) and database variant. Per-harness concrete `#[test]` functions are expanded +//! by the [`sync_tests_for_harness!`] macro. + +use crate::{ + journal::contiguous::Contiguous, + merkle::{self, journaled::Config as MerkleConfig, mmb, mmr, Family, Location}, + qmdb::{ + self, + keyless::{self, variable, Operation}, + sync::{ + self, + engine::{Config, NextStep}, + resolver::{tests::FailResolver, Resolver}, + Engine, Target, + }, + }, +}; +use commonware_codec::Encode; +use commonware_cryptography::{sha256, Sha256}; +use commonware_runtime::{ + buffer::paged::CacheRef, deterministic, BufferPooler, Metrics, Runner as _, +}; +use commonware_utils::{channel::mpsc, non_empty_range, test_rng_seeded, NZUsize, NZU16, NZU64}; +use rand::RngCore as _; +use std::{ + future::Future, + num::{NonZeroU16, NonZeroU64, NonZeroUsize}, + sync::Arc, +}; + +pub(crate) type DbOf = ::Db; +pub(crate) type OpOf = as qmdb::sync::Database>::Op; +pub(crate) type ConfigOf = as qmdb::sync::Database>::Config; +pub(crate) type FamilyOf = as qmdb::sync::Database>::Family; +pub(crate) type JournalOf = as qmdb::sync::Database>::Journal; + +const PAGE_SIZE: NonZeroU16 = NZU16!(77); +const PAGE_CACHE_SIZE: NonZeroUsize = NZUsize!(9); + +/// Harness that abstracts per-family/per-variant details so the generic tests below +/// can operate on any keyless database. +pub(crate) trait SyncTestHarness: Sized + 'static { + type Family: merkle::Family; + type Db: qmdb::sync::Database< + Family = Self::Family, + Context = deterministic::Context, + Digest = sha256::Digest, + Config: Clone, + > + Send + + Sync; + type Value: Clone + PartialEq + std::fmt::Debug + Send + Sync + 'static; + + fn config(suffix: &str, pooler: &(impl BufferPooler + Metrics)) -> ConfigOf; + fn create_ops(n: usize) -> Vec>; + fn create_ops_seeded(n: usize, seed: u64) -> Vec>; + fn sample_metadata() -> Self::Value; + + fn init_db(ctx: deterministic::Context) -> impl Future + Send; + fn init_db_with_config( + ctx: deterministic::Context, + config: ConfigOf, + ) -> impl Future + Send; + fn destroy(db: Self::Db) -> impl Future + Send; + fn db_sync(db: &Self::Db) -> impl Future + Send; + + fn apply_ops( + db: Self::Db, + ops: Vec>, + metadata: Option, + ) -> impl Future + Send; + fn prune(db: &mut Self::Db, loc: Location) -> impl Future + Send; + + fn bounds( + db: &Self::Db, + ) -> impl Future>> + Send; + fn db_root(db: &Self::Db) -> sha256::Digest; + fn get_metadata(db: &Self::Db) -> impl Future> + Send; + fn get_value( + db: &Self::Db, + loc: Location, + ) -> impl Future> + Send; + fn op_value(op: &OpOf) -> Option<&Self::Value>; +} + +// ===== Generic tests ===== + +pub(crate) fn test_sync_resolver_fails() +where + OpOf: Encode + Clone + Send + Sync, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let resolver = FailResolver::, OpOf, sha256::Digest>::new(); + let db_config = H::config(&context.next_u64().to_string(), &context); + let config = Config { + context: context.with_label("client"), + target: Target { + root: sha256::Digest::from([0; 32]), + range: non_empty_range!(Location::new(0), Location::new(5)), + }, + resolver, + apply_batch_size: 2, + max_outstanding_requests: 2, + fetch_batch_size: NZU64!(2), + db_config, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + + let result: Result, _> = sync::sync(config).await; + assert!(result.is_err()); + }); +} + +pub(crate) fn test_sync(target_db_ops: usize, fetch_batch_size: NonZeroU64) +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(target_db_ops); + let target_db = + H::apply_ops(target_db, target_ops.clone(), Some(H::sample_metadata())).await; + let bounds = H::bounds(&target_db).await; + let target_op_count = bounds.end; + let target_oldest_retained_loc = bounds.start; + let target_root = H::db_root(&target_db); + + let db_config = H::config(&format!("sync_client_{}", context.next_u64()), &context); + + let target_db = Arc::new(target_db); + let config = Config { + db_config: db_config.clone(), + fetch_batch_size, + target: Target { + root: target_root, + range: non_empty_range!(target_oldest_retained_loc, target_op_count), + }, + context: context.with_label("client"), + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let got_db: DbOf = sync::sync(config).await.unwrap(); + + let bounds = H::bounds(&got_db).await; + assert_eq!(bounds.end, target_op_count); + assert_eq!(bounds.start, target_oldest_retained_loc); + assert_eq!(H::db_root(&got_db), target_root); + + for (i, op) in target_ops.iter().enumerate() { + if let Some(expected_value) = H::op_value(op) { + // +1 because location 0 is the initial commit + let got = H::get_value(&got_db, Location::new(i as u64 + 1)).await; + assert_eq!(got.as_ref(), Some(expected_value)); + } + } + + let new_ops = H::create_ops_seeded(target_db_ops, 1); + let got_db = H::apply_ops(got_db, new_ops.clone(), None).await; + let target_db = Arc::try_unwrap(target_db) + .unwrap_or_else(|_| panic!("target_db should have no other references")); + let target_db = H::apply_ops(target_db, new_ops, None).await; + + assert_eq!(H::db_root(&got_db), H::db_root(&target_db)); + + H::destroy(got_db).await; + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_sync_empty_to_nonempty() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_db = H::apply_ops(target_db, vec![], Some(H::sample_metadata())).await; + + let bounds = H::bounds(&target_db).await; + let target_op_count = bounds.end; + let target_oldest_retained_loc = bounds.start; + let target_root = H::db_root(&target_db); + + let db_config = H::config(&format!("empty_sync_{}", context.next_u64()), &context); + let target_db = Arc::new(target_db); + let config = Config { + db_config, + fetch_batch_size: NZU64!(10), + target: Target { + root: target_root, + range: non_empty_range!(target_oldest_retained_loc, target_op_count), + }, + context: context.with_label("client"), + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let got_db: DbOf = sync::sync(config).await.unwrap(); + + let bounds = H::bounds(&got_db).await; + assert_eq!(bounds.end, target_op_count); + assert_eq!(bounds.start, target_oldest_retained_loc); + assert_eq!(H::db_root(&got_db), target_root); + assert_eq!(H::get_metadata(&got_db).await, Some(H::sample_metadata())); + + H::destroy(got_db).await; + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_sync_database_persistence() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(10); + let target_db = + H::apply_ops(target_db, target_ops.clone(), Some(H::sample_metadata())).await; + + let target_root = H::db_root(&target_db); + let bounds = H::bounds(&target_db).await; + let lower_bound = bounds.start; + let op_count = bounds.end; + + let db_config = H::config("persistence-test", &context); + let client_context = context.with_label("client"); + let target_db = Arc::new(target_db); + let config = Config { + db_config: db_config.clone(), + fetch_batch_size: NZU64!(5), + target: Target { + root: target_root, + range: non_empty_range!(lower_bound, op_count), + }, + context: client_context.clone(), + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let synced_db: DbOf = sync::sync(config).await.unwrap(); + + assert_eq!(H::db_root(&synced_db), target_root); + let expected_root = H::db_root(&synced_db); + let bounds = H::bounds(&synced_db).await; + let expected_op_count = bounds.end; + let expected_oldest_retained_loc = bounds.start; + + H::db_sync(&synced_db).await; + drop(synced_db); + let reopened_db = H::init_db_with_config(context.with_label("reopened"), db_config).await; + + assert_eq!(H::db_root(&reopened_db), expected_root); + let bounds = H::bounds(&reopened_db).await; + assert_eq!(bounds.end, expected_op_count); + assert_eq!(bounds.start, expected_oldest_retained_loc); + + for (i, op) in target_ops.iter().enumerate() { + if let Some(expected_value) = H::op_value(op) { + let got = H::get_value(&reopened_db, Location::new(i as u64 + 1)).await; + assert_eq!(got.as_ref(), Some(expected_value)); + } + } + + H::destroy(reopened_db).await; + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_target_update_during_sync() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + JournalOf: Contiguous, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let initial_ops = H::create_ops(50); + let target_db = H::apply_ops(target_db, initial_ops, None).await; + + let bounds = H::bounds(&target_db).await; + let initial_lower_bound = bounds.start; + let initial_upper_bound = bounds.end; + let initial_root = H::db_root(&target_db); + + let additional_ops = H::create_ops_seeded(25, 1); + let target_db = H::apply_ops(target_db, additional_ops, None).await; + let final_upper_bound = H::bounds(&target_db).await.end; + let final_root = H::db_root(&target_db); + + let target_db = Arc::new(target_db); + + let (update_sender, update_receiver) = mpsc::channel(1); + let client = { + let config = Config { + context: context.with_label("client"), + db_config: H::config(&format!("update_test_{}", context.next_u64()), &context), + target: Target { + root: initial_root, + range: non_empty_range!(initial_lower_bound, initial_upper_bound), + }, + resolver: target_db.clone(), + fetch_batch_size: NZU64!(2), + max_outstanding_requests: 10, + apply_batch_size: 1024, + update_rx: Some(update_receiver), + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 1, + }; + let mut client: Engine, _> = Engine::new(config).await.unwrap(); + loop { + client = match client.step().await.unwrap() { + NextStep::Continue(new_client) => new_client, + NextStep::Complete(_) => panic!("client should not be complete"), + }; + let log_size = Contiguous::size(client.journal()).await; + if log_size > *initial_lower_bound { + break client; + } + } + }; + + update_sender + .send(Target { + root: final_root, + range: non_empty_range!(initial_lower_bound, final_upper_bound), + }) + .await + .unwrap(); + + let synced_db = client.sync().await.unwrap(); + assert_eq!(H::db_root(&synced_db), final_root); + + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); + { + let bounds = H::bounds(&synced_db).await; + let target_bounds = H::bounds(&target_db).await; + assert_eq!(bounds.end, target_bounds.end); + assert_eq!(bounds.start, target_bounds.start); + assert_eq!(H::db_root(&synced_db), H::db_root(&target_db)); + } + + H::destroy(synced_db).await; + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_sync_subset_of_target_database() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(30); + let target_db = H::apply_ops(target_db, target_ops[..29].to_vec(), None).await; + + let target_root = H::db_root(&target_db); + let bounds = H::bounds(&target_db).await; + let lower_bound = bounds.start; + let op_count = bounds.end; + + let target_db = H::apply_ops(target_db, target_ops[29..].to_vec(), None).await; + + let target_db = Arc::new(target_db); + let config = Config { + db_config: H::config(&format!("subset_{}", context.next_u64()), &context), + fetch_batch_size: NZU64!(10), + target: Target { + root: target_root, + range: non_empty_range!(lower_bound, op_count), + }, + context: context.with_label("client"), + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let synced_db: DbOf = sync::sync(config).await.unwrap(); + + assert_eq!(H::db_root(&synced_db), target_root); + assert_eq!(H::bounds(&synced_db).await.end, op_count); + + H::destroy(synced_db).await; + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_sync_use_existing_db_partial_match() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let original_ops = H::create_ops(50); + + let target_db = H::init_db(context.with_label("target")).await; + let sync_db_config = H::config(&format!("partial_{}", context.next_u64()), &context); + let client_context = context.with_label("client"); + let sync_db = H::init_db_with_config(client_context.clone(), sync_db_config.clone()).await; + + let target_db = H::apply_ops(target_db, original_ops.clone(), None).await; + let sync_db = H::apply_ops(sync_db, original_ops, None).await; + drop(sync_db); + + let last_op = H::create_ops_seeded(1, 1); + let target_db = H::apply_ops(target_db, last_op, None).await; + let root = H::db_root(&target_db); + let bounds = H::bounds(&target_db).await; + let lower_bound = bounds.start; + let upper_bound = bounds.end; + + let target_db = Arc::new(target_db); + let config = Config { + db_config: sync_db_config, + fetch_batch_size: NZU64!(10), + target: Target { + root, + range: non_empty_range!(lower_bound, upper_bound), + }, + context: context.with_label("sync"), + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let sync_db: DbOf = sync::sync(config).await.unwrap(); + + assert_eq!(H::bounds(&sync_db).await.end, upper_bound); + assert_eq!(H::db_root(&sync_db), root); + + H::destroy(sync_db).await; + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_sync_use_existing_db_exact_match() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_ops = H::create_ops(40); + + let target_db = H::init_db(context.with_label("target")).await; + let sync_config = H::config(&format!("exact_{}", context.next_u64()), &context); + let client_context = context.with_label("client"); + let sync_db = H::init_db_with_config(client_context.clone(), sync_config.clone()).await; + + let target_db = H::apply_ops(target_db, target_ops.clone(), None).await; + let sync_db = H::apply_ops(sync_db, target_ops, None).await; + drop(sync_db); + + let root = H::db_root(&target_db); + let bounds = H::bounds(&target_db).await; + let lower_bound = bounds.start; + let upper_bound = bounds.end; + + let resolver = Arc::new(target_db); + let config = Config { + db_config: sync_config, + fetch_batch_size: NZU64!(10), + target: Target { + root, + range: non_empty_range!(lower_bound, upper_bound), + }, + context: context.with_label("sync"), + resolver: resolver.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: None, + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 8, + }; + let sync_db: DbOf = sync::sync(config).await.unwrap(); + + assert_eq!(H::bounds(&sync_db).await.end, upper_bound); + assert_eq!(H::db_root(&sync_db), root); + + H::destroy(sync_db).await; + let target_db = + Arc::try_unwrap(resolver).unwrap_or_else(|_| panic!("failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_target_update_lower_bound_decrease() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(100); + let mut target_db = H::apply_ops(target_db, target_ops, None).await; + + H::prune(&mut target_db, Location::new(10)).await; + + let bounds = H::bounds(&target_db).await; + let initial_lower_bound = bounds.start; + let initial_upper_bound = bounds.end; + let initial_root = H::db_root(&target_db); + + let (update_sender, update_receiver) = mpsc::channel(1); + let target_db = Arc::new(target_db); + let config = Config { + context: context.with_label("client"), + db_config: H::config(&format!("lb-dec-{}", context.next_u64()), &context), + fetch_batch_size: NZU64!(5), + target: Target { + root: initial_root, + range: non_empty_range!(initial_lower_bound, initial_upper_bound), + }, + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 10, + update_rx: Some(update_receiver), + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 1, + }; + let client: Engine, _> = Engine::new(config).await.unwrap(); + + update_sender + .send(Target { + root: initial_root, + range: non_empty_range!( + initial_lower_bound.checked_sub(1).unwrap(), + initial_upper_bound + ), + }) + .await + .unwrap(); + + let result = client.step().await; + assert!(matches!( + result, + Err(sync::Error::Engine( + sync::EngineError::SyncTargetMovedBackward { .. } + )) + )); + + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_target_update_upper_bound_decrease() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(50); + let target_db = H::apply_ops(target_db, target_ops, None).await; + + let bounds = H::bounds(&target_db).await; + let initial_lower_bound = bounds.start; + let initial_upper_bound = bounds.end; + let initial_root = H::db_root(&target_db); + + let (update_sender, update_receiver) = mpsc::channel(1); + let target_db = Arc::new(target_db); + let config = Config { + context: context.with_label("client"), + db_config: H::config(&format!("ub-dec-{}", context.next_u64()), &context), + fetch_batch_size: NZU64!(5), + target: Target { + root: initial_root, + range: non_empty_range!(initial_lower_bound, initial_upper_bound), + }, + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 10, + update_rx: Some(update_receiver), + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 1, + }; + let client: Engine, _> = Engine::new(config).await.unwrap(); + + update_sender + .send(Target { + root: initial_root, + range: non_empty_range!(initial_lower_bound, initial_upper_bound - 1), + }) + .await + .unwrap(); + + let result = client.step().await; + assert!(matches!( + result, + Err(sync::Error::Engine( + sync::EngineError::SyncTargetMovedBackward { .. } + )) + )); + + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_target_update_bounds_increase() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(100); + let target_db = H::apply_ops(target_db, target_ops, None).await; + + let bounds = H::bounds(&target_db).await; + let initial_lower_bound = bounds.start; + let initial_upper_bound = bounds.end; + let initial_root = H::db_root(&target_db); + + let more_ops = H::create_ops_seeded(5, 1); + let mut target_db = H::apply_ops(target_db, more_ops, None).await; + + H::prune(&mut target_db, Location::new(10)).await; + let target_db = H::apply_ops(target_db, vec![], None).await; + + let bounds = H::bounds(&target_db).await; + let final_lower_bound = bounds.start; + let final_upper_bound = bounds.end; + let final_root = H::db_root(&target_db); + + assert_ne!(final_lower_bound, initial_lower_bound); + assert_ne!(final_upper_bound, initial_upper_bound); + + let (update_sender, update_receiver) = mpsc::channel(1); + let target_db = Arc::new(target_db); + let config = Config { + context: context.with_label("client"), + db_config: H::config(&format!("bounds_inc_{}", context.next_u64()), &context), + fetch_batch_size: NZU64!(1), + target: Target { + root: initial_root, + range: non_empty_range!(initial_lower_bound, initial_upper_bound), + }, + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 1, + update_rx: Some(update_receiver), + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 1, + }; + + update_sender + .send(Target { + root: final_root, + range: non_empty_range!(final_lower_bound, final_upper_bound), + }) + .await + .unwrap(); + + let synced_db: DbOf = sync::sync(config).await.unwrap(); + + assert_eq!(H::db_root(&synced_db), final_root); + let bounds = H::bounds(&synced_db).await; + assert_eq!(bounds.end, final_upper_bound); + assert_eq!(bounds.start, final_lower_bound); + + H::destroy(synced_db).await; + let target_db = + Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("Failed to unwrap Arc")); + H::destroy(target_db).await; + }); +} + +pub(crate) fn test_target_update_on_done_client() +where + OpOf: Encode + Clone + Send + Sync, + Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, +{ + let executor = deterministic::Runner::default(); + executor.start(|mut context| async move { + let target_db = H::init_db(context.with_label("target")).await; + let target_ops = H::create_ops(10); + let target_db = H::apply_ops(target_db, target_ops, None).await; + + let bounds = H::bounds(&target_db).await; + let lower_bound = bounds.start; + let upper_bound = bounds.end; + let root = H::db_root(&target_db); + + let (update_sender, update_receiver) = mpsc::channel(1); + let target_db = Arc::new(target_db); + let config = Config { + context: context.with_label("client"), + db_config: H::config(&format!("done_{}", context.next_u64()), &context), + fetch_batch_size: NZU64!(20), + target: Target { + root, + range: non_empty_range!(lower_bound, upper_bound), + }, + resolver: target_db.clone(), + apply_batch_size: 1024, + max_outstanding_requests: 10, + update_rx: Some(update_receiver), + finish_rx: None, + reached_target_tx: None, + max_retained_roots: 1, + }; + + let synced_db: DbOf = sync::sync(config).await.unwrap(); + + let _ = update_sender + .send(Target { + root: sha256::Digest::from([2u8; 32]), + range: non_empty_range!(lower_bound + 1, upper_bound + 1), + }) + .await; + + assert_eq!(H::db_root(&synced_db), root); + let bounds = H::bounds(&synced_db).await; + assert_eq!(bounds.end, upper_bound); + assert_eq!(bounds.start, lower_bound); + + H::destroy(synced_db).await; + H::destroy(Arc::try_unwrap(target_db).unwrap_or_else(|_| panic!("failed to unwrap Arc"))) + .await; + }); +} + +// ===== Harness implementations ===== + +pub(crate) mod harnesses { + use super::*; + + type VariableDb = variable::Db, Sha256>; + type VariableOp = Operation>>; + + fn variable_config( + suffix: &str, + pooler: &(impl BufferPooler + Metrics), + ) -> variable::Config<(commonware_codec::RangeCfg, ())> { + const ITEMS_PER_SECTION: NonZeroU64 = NZU64!(5); + + let page_cache = + CacheRef::from_pooler(&pooler.with_label("page_cache"), PAGE_SIZE, PAGE_CACHE_SIZE); + keyless::Config { + merkle: MerkleConfig { + journal_partition: format!("journal-{suffix}"), + metadata_partition: format!("metadata-{suffix}"), + items_per_blob: NZU64!(11), + write_buffer: NZUsize!(1024), + thread_pool: None, + page_cache: page_cache.clone(), + }, + log: crate::journal::contiguous::variable::Config { + partition: format!("log-{suffix}"), + items_per_section: ITEMS_PER_SECTION, + compression: None, + codec_config: ((0..=10000).into(), ()), + page_cache, + write_buffer: NZUsize!(1024), + }, + } + } + + fn variable_create_ops_seeded(n: usize, seed: u64) -> Vec { + let mut rng = test_rng_seeded(seed); + let mut ops = Vec::with_capacity(n); + for _ in 0..n { + let len = (rng.next_u32() % 100 + 1) as usize; + let mut value = vec![0u8; len]; + rng.fill_bytes(&mut value); + ops.push(Operation::Append(value)); + } + ops + } + + async fn variable_apply_ops( + mut db: VariableDb, + ops: Vec, + metadata: Option>, + ) -> VariableDb { + let mut batch = db.new_batch(); + for op in ops { + match op { + Operation::Append(value) => { + batch = batch.append(value); + } + Operation::Commit(_) => { + panic!("Commit operation not supported in apply_ops"); + } + } + } + let merkleized = batch.merkleize(&db, metadata); + db.apply_batch(merkleized).await.unwrap(); + db + } + + pub(crate) struct VariableHarness(std::marker::PhantomData); + + impl SyncTestHarness for VariableHarness { + type Family = F; + type Db = VariableDb; + type Value = Vec; + + fn config(suffix: &str, pooler: &(impl BufferPooler + Metrics)) -> ConfigOf { + variable_config(suffix, pooler) + } + + fn create_ops(n: usize) -> Vec> { + variable_create_ops_seeded(n, 0) + } + + fn create_ops_seeded(n: usize, seed: u64) -> Vec> { + variable_create_ops_seeded(n, seed) + } + + fn sample_metadata() -> Self::Value { + vec![42] + } + + async fn init_db(mut ctx: deterministic::Context) -> Self::Db { + let seed = ctx.next_u64(); + let config = variable_config(&format!("sync-test-{seed}"), &ctx); + VariableDb::::init(ctx, config).await.unwrap() + } + + async fn init_db_with_config( + ctx: deterministic::Context, + config: ConfigOf, + ) -> Self::Db { + VariableDb::::init(ctx, config).await.unwrap() + } + + async fn destroy(db: Self::Db) { + db.destroy().await.unwrap(); + } + + async fn db_sync(db: &Self::Db) { + db.sync().await.unwrap(); + } + + async fn apply_ops( + db: Self::Db, + ops: Vec>, + metadata: Option, + ) -> Self::Db { + variable_apply_ops::(db, ops, metadata).await + } + + async fn prune(db: &mut Self::Db, loc: Location) { + db.prune(loc).await.unwrap(); + } + + async fn bounds(db: &Self::Db) -> std::ops::Range> { + db.bounds().await + } + + fn db_root(db: &Self::Db) -> sha256::Digest { + db.root() + } + + async fn get_metadata(db: &Self::Db) -> Option { + db.get_metadata().await.unwrap() + } + + async fn get_value(db: &Self::Db, loc: Location) -> Option { + db.get(loc).await.unwrap() + } + + fn op_value(op: &OpOf) -> Option<&Self::Value> { + match op { + Operation::Append(value) => Some(value), + Operation::Commit(_) => None, + } + } + } + + pub(crate) type VariableMmrHarness = VariableHarness; + pub(crate) type VariableMmbHarness = VariableHarness; +} + +// ===== Test Generation Macro ===== + +macro_rules! sync_tests_for_harness { + ($harness:ty, $mod_name:ident) => { + mod $mod_name { + use super::harnesses; + use commonware_macros::test_traced; + use rstest::rstest; + use std::num::NonZeroU64; + + #[test_traced("WARN")] + fn test_sync_resolver_fails() { + super::test_sync_resolver_fails::<$harness>(); + } + + #[rstest] + #[case::singleton_batch_size_one(1, 1)] + #[case::singleton_batch_size_gt_db_size(1, 2)] + #[case::batch_size_one(1000, 1)] + #[case::floor_div_db_batch_size(1000, 3)] + #[case::floor_div_db_batch_size_2(1000, 999)] + #[case::div_db_batch_size(1000, 100)] + #[case::db_size_eq_batch_size(1000, 1000)] + #[case::batch_size_gt_db_size(1000, 1001)] + fn test_sync(#[case] target_db_ops: usize, #[case] fetch_batch_size: u64) { + super::test_sync::<$harness>( + target_db_ops, + NonZeroU64::new(fetch_batch_size).unwrap(), + ); + } + + #[test_traced("WARN")] + fn test_sync_empty_to_nonempty() { + super::test_sync_empty_to_nonempty::<$harness>(); + } + + #[test_traced("WARN")] + fn test_sync_database_persistence() { + super::test_sync_database_persistence::<$harness>(); + } + + #[test_traced("WARN")] + fn test_target_update_during_sync() { + super::test_target_update_during_sync::<$harness>(); + } + + #[test] + fn test_sync_subset_of_target_database() { + super::test_sync_subset_of_target_database::<$harness>(); + } + + #[test] + fn test_sync_use_existing_db_partial_match() { + super::test_sync_use_existing_db_partial_match::<$harness>(); + } + + #[test] + fn test_sync_use_existing_db_exact_match() { + super::test_sync_use_existing_db_exact_match::<$harness>(); + } + + #[test_traced("WARN")] + fn test_target_update_lower_bound_decrease() { + super::test_target_update_lower_bound_decrease::<$harness>(); + } + + #[test_traced("WARN")] + fn test_target_update_upper_bound_decrease() { + super::test_target_update_upper_bound_decrease::<$harness>(); + } + + #[test_traced("WARN")] + fn test_target_update_bounds_increase() { + super::test_target_update_bounds_increase::<$harness>(); + } + + #[test_traced("WARN")] + fn test_target_update_on_done_client() { + super::test_target_update_on_done_client::<$harness>(); + } + } + }; +} + +sync_tests_for_harness!(harnesses::VariableMmrHarness, variable_mmr); +sync_tests_for_harness!(harnesses::VariableMmbHarness, variable_mmb); diff --git a/storage/src/qmdb/sync/mod.rs b/storage/src/qmdb/sync/mod.rs index ea427c38682..8329861d6a1 100644 --- a/storage/src/qmdb/sync/mod.rs +++ b/storage/src/qmdb/sync/mod.rs @@ -25,7 +25,7 @@ pub use target::Target; mod requests; -/// A [`Resolver`] whose associated types match a specific [`Database`]. +/// A [`Resolver`] whose associated types match a specific `Database`. /// /// Blanket-impled for any matching `Resolver`, so callers never implement this directly. pub trait DbResolver: From fe305b9cad1e2a7e6c27d9fe5ab11063e759b8d3 Mon Sep 17 00:00:00 2001 From: Roberto Bayardo Date: Sun, 19 Apr 2026 21:22:17 -0700 Subject: [PATCH 3/8] cleanup --- examples/sync/src/databases/current.rs | 1 - .../fuzz_targets/current_crash_recovery.rs | 9 +- .../fuzz_targets/current_mmb_prune_grow.rs | 7 +- .../current_ordered_operations.rs | 4 +- .../current_unordered_operations.rs | 4 +- storage/src/qmdb/any/db.rs | 12 +- storage/src/qmdb/any/sync/mod.rs | 17 +- storage/src/qmdb/any/sync/tests.rs | 95 +++--- storage/src/qmdb/any/traits.rs | 8 + storage/src/qmdb/benches/generate.rs | 2 +- storage/src/qmdb/benches/init.rs | 2 +- storage/src/qmdb/current/db.rs | 131 ++++----- storage/src/qmdb/current/grafting.rs | 3 +- storage/src/qmdb/current/mod.rs | 80 ++--- storage/src/qmdb/current/ordered/fixed.rs | 3 +- storage/src/qmdb/current/sync/mod.rs | 6 +- storage/src/qmdb/current/sync/tests.rs | 275 +++--------------- storage/src/qmdb/sync/database.rs | 20 +- 18 files changed, 234 insertions(+), 445 deletions(-) diff --git a/examples/sync/src/databases/current.rs b/examples/sync/src/databases/current.rs index e9628900e3b..88ac2876d9b 100644 --- a/examples/sync/src/databases/current.rs +++ b/examples/sync/src/databases/current.rs @@ -141,7 +141,6 @@ where async fn sync_boundary(&self) -> Location { self.sync_boundary() - .expect("sync_boundary should not overflow") } fn historical_proof( diff --git a/storage/fuzz/fuzz_targets/current_crash_recovery.rs b/storage/fuzz/fuzz_targets/current_crash_recovery.rs index 0378904ea74..24a263b91a0 100644 --- a/storage/fuzz/fuzz_targets/current_crash_recovery.rs +++ b/storage/fuzz/fuzz_targets/current_crash_recovery.rs @@ -265,10 +265,7 @@ fn fuzz(input: FuzzInput) { { break; } - let Ok(boundary) = db.sync_boundary() else { - break; - }; - if db.prune(boundary).await.is_err() { + if db.prune(db.sync_boundary()).await.is_err() { break; } } @@ -327,9 +324,7 @@ fn fuzz(input: FuzzInput) { } // Verify range proofs over the recovered DB. - let floor = *db - .sync_boundary() - .expect("sync_boundary should not overflow"); + let floor = *db.sync_boundary(); let size = *db.bounds().await.end; for i in floor..size { let loc = Location::new(i); diff --git a/storage/fuzz/fuzz_targets/current_mmb_prune_grow.rs b/storage/fuzz/fuzz_targets/current_mmb_prune_grow.rs index c5b131aee03..e6f60ef86f6 100644 --- a/storage/fuzz/fuzz_targets/current_mmb_prune_grow.rs +++ b/storage/fuzz/fuzz_targets/current_mmb_prune_grow.rs @@ -215,10 +215,9 @@ async fn commit_pending( } async fn prune_to_floor(db: &mut Db, reference_db: &Db, context: &str) { - let boundary = db - .sync_boundary() - .expect("sync_boundary should not overflow"); - db.prune(boundary).await.expect("prune should not fail"); + db.prune(db.sync_boundary()) + .await + .expect("prune should not fail"); assert_matches_reference(db, reference_db, context).await; } diff --git a/storage/fuzz/fuzz_targets/current_ordered_operations.rs b/storage/fuzz/fuzz_targets/current_ordered_operations.rs index 501bb2a9bcf..d172a94a2ff 100644 --- a/storage/fuzz/fuzz_targets/current_ordered_operations.rs +++ b/storage/fuzz/fuzz_targets/current_ordered_operations.rs @@ -215,7 +215,7 @@ fn fuzz(data: FuzzInput) { &mut pending_inserts, &mut pending_deletes, ).await; committed_op_count = db.bounds().await.end; - db.prune(db.sync_boundary().expect("sync_boundary should not overflow")).await.expect("Prune should not fail"); + db.prune(db.sync_boundary()).await.expect("Prune should not fail"); } CurrentOperation::Root => { @@ -243,7 +243,7 @@ fn fuzz(data: FuzzInput) { let current_op_count = db.bounds().await.end; let start_loc = Location::new(start_loc % *current_op_count); - let oldest_loc = db.sync_boundary().expect("sync_boundary should not overflow"); + let oldest_loc = db.sync_boundary(); if start_loc >= oldest_loc { let (proof, ops, chunks) = db .range_proof(&mut hasher, start_loc, *max_ops) diff --git a/storage/fuzz/fuzz_targets/current_unordered_operations.rs b/storage/fuzz/fuzz_targets/current_unordered_operations.rs index cf8022cf36b..9ddf61e82ec 100644 --- a/storage/fuzz/fuzz_targets/current_unordered_operations.rs +++ b/storage/fuzz/fuzz_targets/current_unordered_operations.rs @@ -197,7 +197,7 @@ fn fuzz(data: FuzzInput) { CurrentOperation::Prune => { commit_pending(&mut db, &mut pending_writes, &mut committed_state, &mut pending_expected).await; committed_op_count = db.bounds().await.end; - db.prune(db.sync_boundary().expect("sync_boundary should not overflow")).await.expect("Prune should not fail"); + db.prune(db.sync_boundary()).await.expect("Prune should not fail"); } CurrentOperation::Root => { @@ -218,7 +218,7 @@ fn fuzz(data: FuzzInput) { let current_op_count = db.bounds().await.end; let start_loc = Location::new(start_loc % *current_op_count); - let oldest_loc = db.sync_boundary().expect("sync_boundary should not overflow"); + let oldest_loc = db.sync_boundary(); if start_loc >= oldest_loc { let (proof, ops, chunks) = db .range_proof(&mut hasher, start_loc, *max_ops) diff --git a/storage/src/qmdb/any/db.rs b/storage/src/qmdb/any/db.rs index f9eac93e904..fbcc460f09d 100644 --- a/storage/src/qmdb/any/db.rs +++ b/storage/src/qmdb/any/db.rs @@ -100,19 +100,13 @@ where { /// Return the inactivity floor location. This is the location before which all operations are /// known to be inactive. Operations before this point can be safely pruned. - /// - /// This is an implementation detail of the activity tracking; external callers should - /// prefer [`Self::sync_boundary`] when constructing a sync target. + #[cfg(any(test, feature = "test-traits"))] pub(crate) const fn inactivity_floor_loc(&self) -> Location { self.inactivity_floor_loc } - /// Return the most recent location from which this database can safely be synced. - /// - /// Callers constructing a sync [`Target`](crate::qmdb::sync::Target) may use this value, or - /// any earlier retained location, as `range.start`. For `any` databases this equals the - /// inactivity floor; the receiver's reconstruction does not require chunk alignment, so any - /// retained earlier location is also valid. + /// Return the most recent location from which this database can safely be synced, and the + /// upper bound on [`Self::prune`]'s `loc`. For `any`, this equals the inactivity floor. pub const fn sync_boundary(&self) -> Location { self.inactivity_floor_loc } diff --git a/storage/src/qmdb/any/sync/mod.rs b/storage/src/qmdb/any/sync/mod.rs index 394f6cebadf..4816d30f602 100644 --- a/storage/src/qmdb/any/sync/mod.rs +++ b/storage/src/qmdb/any/sync/mod.rs @@ -50,16 +50,10 @@ pub(crate) mod tests; /// /// Shared across [crate::qmdb::any] and [crate::qmdb::current] sync because both /// build on the same operations-MMR layout and share the same merkle partition. -/// -/// # Caller contract -/// -/// `target.range.start()` **must** equal the committed inactivity floor of the -/// target state (i.e. the floor carried by the last `CommitFloor` op). Only the -/// persisted tree size and root are checked; the merkle pruning boundary is not. -/// Callers that set `target.range.start()` below the committed floor (or that -/// prune their own database past the committed floor) can cause a later -/// [`qmdb::sync::Database::from_sync_result`] rebuild to fail with `MissingNode` -/// even though this function returned `true`. +/// Verifies only that the persisted tree size and root match; the merkle pruning +/// boundary is not re-checked. Callers must keep their local pruning point at or +/// below `target.range.start()` or a later +/// [`qmdb::sync::Database::from_sync_result`] rebuild may fail. pub async fn has_local_target_state( context: E, merkle_config: journaled::Config, @@ -78,7 +72,8 @@ where ) .await; // Size + root match implies the last CommitFloor op (and therefore the - // committed inactivity floor) matches, per the caller contract above. + // size + root identify a unique state, so if they match the target's we can reuse + // the persisted DB without fetching boundary pins. matches!( peek, Ok(Some((_, journal_leaves, root))) diff --git a/storage/src/qmdb/any/sync/tests.rs b/storage/src/qmdb/any/sync/tests.rs index 4f73ca312d2..951a349a4c0 100644 --- a/storage/src/qmdb/any/sync/tests.rs +++ b/storage/src/qmdb/any/sync/tests.rs @@ -229,7 +229,7 @@ where target_db = H::apply_ops(target_db, target_ops).await; // commit already done in apply_ops target_db - .prune(target_db.inactivity_floor_loc().await) + .prune(target_db.sync_boundary().await) .await .unwrap(); @@ -237,7 +237,7 @@ where let target_inactivity_floor = target_db.inactivity_floor_loc().await; let sync_root = H::sync_target_root(&target_db); let verification_root = target_db.root(); - let lower_bound = target_db.inactivity_floor_loc().await; + let lower_bound = target_db.sync_boundary().await; // Configure sync let db_config = H::config(&context.next_u64().to_string(), &context); @@ -316,7 +316,7 @@ where let upper_bound = target_db.bounds().await.end; let sync_root = H::sync_target_root(&target_db); let verification_root = target_db.root(); - let lower_bound = target_db.inactivity_floor_loc().await; + let lower_bound = target_db.sync_boundary().await; // Add another operation after the sync range let final_op = target_ops[target_db_ops - 1].clone(); @@ -346,7 +346,7 @@ where let synced_db: H::Db = sync::sync(config).await.unwrap(); // Verify the synced database has the correct range of operations - assert_eq!(synced_db.inactivity_floor_loc().await, lower_bound); + assert_eq!(synced_db.sync_boundary().await, lower_bound); assert_eq!(synced_db.bounds().await.end, upper_bound); // Verify the final root digest matches our target @@ -397,7 +397,7 @@ where let sync_root = H::sync_target_root(&target_db); let verification_root = target_db.root(); - let lower_bound = target_db.inactivity_floor_loc().await; + let lower_bound = target_db.sync_boundary().await; let upper_bound = target_db.bounds().await.end; // Reopen the sync database and sync it to the target database @@ -427,7 +427,6 @@ where synced_db.inactivity_floor_loc().await, target_db.inactivity_floor_loc().await ); - assert_eq!(synced_db.inactivity_floor_loc().await, lower_bound); assert_eq!(bounds.end, target_db.bounds().await.end); // Verify the root digest matches the target assert_eq!(synced_db.root(), verification_root); @@ -485,13 +484,10 @@ where // commit already done in apply_ops target_db - .prune(target_db.inactivity_floor_loc().await) - .await - .unwrap(); - sync_db - .prune(sync_db.inactivity_floor_loc().await) + .prune(target_db.sync_boundary().await) .await .unwrap(); + sync_db.prune(sync_db.sync_boundary().await).await.unwrap(); sync_db.sync().await.unwrap(); drop(sync_db); @@ -499,7 +495,7 @@ where // Capture target state let sync_root = H::sync_target_root(&target_db); let verification_root = target_db.root(); - let lower_bound = target_db.inactivity_floor_loc().await; + let lower_bound = target_db.sync_boundary().await; let upper_bound = target_db.bounds().await.end; // sync_db should never ask the resolver for operations @@ -528,7 +524,7 @@ where let bounds = synced_db.bounds().await; assert_eq!(bounds.end, upper_bound); assert_eq!(bounds.end, target_db.bounds().await.end); - assert_eq!(synced_db.inactivity_floor_loc().await, lower_bound); + assert_eq!(synced_db.sync_boundary().await, lower_bound); // Verify the root digest matches the target assert_eq!(synced_db.root(), verification_root); @@ -562,8 +558,14 @@ where target_db = H::apply_ops(target_db, target_ops).await; // commit already done in apply_ops - // Capture initial target state + // Use inactivity_floor as range.start so we have a non-zero bound to decrement. + // The engine only checks that range.start does not decrease on updates; it doesn't + // require range.start to equal sync_boundary here. let initial_lower_bound = target_db.inactivity_floor_loc().await; + assert!( + *initial_lower_bound > 0, + "test setup requires non-zero inactivity floor" + ); let initial_upper_bound = target_db.bounds().await.end; let initial_root = H::sync_target_root(&target_db); @@ -632,7 +634,7 @@ where // commit already done in apply_ops // Capture initial target state - let initial_lower_bound = target_db.inactivity_floor_loc().await; + let initial_lower_bound = target_db.sync_boundary().await; let initial_upper_bound = target_db.bounds().await.end; let initial_root = H::sync_target_root(&target_db); @@ -701,7 +703,7 @@ where // commit already done in apply_ops // Capture initial target state - let initial_lower_bound = target_db.inactivity_floor_loc().await; + let initial_lower_bound = target_db.sync_boundary().await; let initial_upper_bound = target_db.bounds().await.end; let initial_root = H::sync_target_root(&target_db); @@ -713,7 +715,7 @@ where // commit already done in apply_ops // Capture new target state - let new_lower_bound = target_db.inactivity_floor_loc().await; + let new_lower_bound = target_db.sync_boundary().await; let new_upper_bound = target_db.bounds().await.end; let new_sync_root = H::sync_target_root(&target_db); let new_verification_root = target_db.root(); @@ -754,7 +756,7 @@ where // Verify the synced database has the expected final state assert_eq!(synced_db.root(), new_verification_root); assert_eq!(synced_db.bounds().await.end, new_upper_bound); - assert_eq!(synced_db.inactivity_floor_loc().await, new_lower_bound); + assert_eq!(synced_db.sync_boundary().await, new_lower_bound); synced_db.destroy().await.unwrap(); @@ -786,7 +788,7 @@ where // commit already done in apply_ops // Capture target state - let lower_bound = target_db.inactivity_floor_loc().await; + let lower_bound = target_db.sync_boundary().await; let upper_bound = target_db.bounds().await.end; let sync_root = H::sync_target_root(&target_db); let verification_root = target_db.root(); @@ -827,7 +829,7 @@ where // Verify the synced database has the expected state assert_eq!(synced_db.root(), verification_root); assert_eq!(synced_db.bounds().await.end, upper_bound); - assert_eq!(synced_db.inactivity_floor_loc().await, lower_bound); + assert_eq!(synced_db.sync_boundary().await, lower_bound); synced_db.destroy().await.unwrap(); @@ -853,13 +855,13 @@ where let initial_target = Target { root: H::sync_target_root(&target_db), range: non_empty_range!( - target_db.inactivity_floor_loc().await, + target_db.sync_boundary().await, target_db.bounds().await.end ), }; target_db = H::apply_ops(target_db, H::create_ops_seeded(5, 1)).await; - let updated_lower_bound = target_db.inactivity_floor_loc().await; + let updated_lower_bound = target_db.sync_boundary().await; let updated_upper_bound = target_db.bounds().await.end; let updated_target = Target { root: H::sync_target_root(&target_db), @@ -931,7 +933,7 @@ where .expect("sync should succeed after finish signal"); assert_eq!(synced_db.root(), updated_verification_root); assert_eq!(synced_db.bounds().await.end, updated_upper_bound); - assert_eq!(synced_db.inactivity_floor_loc().await, updated_lower_bound); + assert_eq!(synced_db.sync_boundary().await, updated_lower_bound); synced_db.destroy().await.unwrap(); Arc::try_unwrap(target_db) @@ -953,7 +955,7 @@ where executor.start(|mut context| async move { let mut target_db = H::init_db(context.with_label("target")).await; target_db = H::apply_ops(target_db, H::create_ops(30)).await; - let lower_bound = target_db.inactivity_floor_loc().await; + let lower_bound = target_db.sync_boundary().await; let upper_bound = target_db.bounds().await.end; let target = Target { root: H::sync_target_root(&target_db), @@ -994,7 +996,7 @@ where assert_eq!(reached, target); assert_eq!(synced_db.root(), verification_root); assert_eq!(synced_db.bounds().await.end, upper_bound); - assert_eq!(synced_db.inactivity_floor_loc().await, lower_bound); + assert_eq!(synced_db.sync_boundary().await, lower_bound); synced_db.destroy().await.unwrap(); Arc::try_unwrap(target_db) @@ -1016,7 +1018,7 @@ where executor.start(|mut context| async move { let mut target_db = H::init_db(context.with_label("target")).await; target_db = H::apply_ops(target_db, H::create_ops(10)).await; - let lower_bound = target_db.inactivity_floor_loc().await; + let lower_bound = target_db.sync_boundary().await; let upper_bound = target_db.bounds().await.end; let (finish_sender, finish_receiver) = mpsc::channel(1); @@ -1065,7 +1067,7 @@ where executor.start(|mut context| async move { let mut target_db = H::init_db(context.with_label("target")).await; target_db = H::apply_ops(target_db, H::create_ops(10)).await; - let lower_bound = target_db.inactivity_floor_loc().await; + let lower_bound = target_db.sync_boundary().await; let upper_bound = target_db.bounds().await.end; let verification_root = target_db.root(); @@ -1095,7 +1097,7 @@ where .expect("sync should succeed when reached-target receiver is dropped"); assert_eq!(synced_db.root(), verification_root); assert_eq!(synced_db.bounds().await.end, upper_bound); - assert_eq!(synced_db.inactivity_floor_loc().await, lower_bound); + assert_eq!(synced_db.sync_boundary().await, lower_bound); synced_db.destroy().await.unwrap(); Arc::try_unwrap(target_db) @@ -1125,7 +1127,7 @@ pub(crate) fn test_target_update_during_sync( // commit already done in apply_ops // Capture initial target state - let initial_lower_bound = target_db.inactivity_floor_loc().await; + let initial_lower_bound = target_db.sync_boundary().await; let initial_upper_bound = target_db.bounds().await.end; let initial_sync_root = H::sync_target_root(&target_db); @@ -1175,7 +1177,7 @@ pub(crate) fn test_target_update_during_sync( let db = H::apply_ops(db, additional_ops_data).await; // Capture new target state - let new_lower_bound = db.inactivity_floor_loc().await; + let new_lower_bound = db.sync_boundary().await; let new_upper_bound = db.bounds().await.end; let new_sync_root = H::sync_target_root(&db); let new_verification_root = db.root(); @@ -1238,7 +1240,7 @@ where // Capture target state let sync_root = H::sync_target_root(&target_db); let verification_root = target_db.root(); - let lower_bound = target_db.inactivity_floor_loc().await; + let lower_bound = target_db.sync_boundary().await; let upper_bound = target_db.bounds().await.end; // Perform sync @@ -1308,7 +1310,7 @@ where target_db = H::apply_ops(target_db, target_ops).await; let sync_root = H::sync_target_root(&target_db); - let lower_bound = target_db.inactivity_floor_loc().await; + let lower_bound = target_db.sync_boundary().await; let upper_bound = target_db.bounds().await.end; let target_db = Arc::new(target_db); @@ -1365,13 +1367,13 @@ where db = H::apply_ops(db, ops).await; // commit already done in apply_ops - let sync_lower_bound = db.inactivity_floor_loc().await; + let sync_lower_bound = db.sync_boundary().await; let bounds = db.bounds().await; let sync_upper_bound = bounds.end; let target_db_op_count = bounds.end; let target_db_inactivity_floor_loc = db.inactivity_floor_loc().await; - let pinned_nodes = db.pinned_nodes_at(db.inactivity_floor_loc().await).await; + let pinned_nodes = db.pinned_nodes_at(sync_lower_bound).await; let (_, journal) = db.into_log_components(); let sync_db: DbOf = as qmdb::sync::Database>::from_sync_result( @@ -1391,7 +1393,7 @@ where sync_db.inactivity_floor_loc().await, target_db_inactivity_floor_loc ); - assert_eq!(sync_db.inactivity_floor_loc().await, sync_lower_bound); + assert_eq!(sync_db.sync_boundary().await, sync_lower_bound); sync_db.destroy().await.unwrap(); }); @@ -1418,15 +1420,12 @@ where target_db = H::apply_ops(target_db, original_ops.clone()).await; // commit already done in apply_ops target_db - .prune(target_db.inactivity_floor_loc().await) + .prune(target_db.sync_boundary().await) .await .unwrap(); sync_db = H::apply_ops(sync_db, original_ops.clone()).await; // commit already done in apply_ops - sync_db - .prune(sync_db.inactivity_floor_loc().await) - .await - .unwrap(); + sync_db.prune(sync_db.sync_boundary().await).await.unwrap(); sync_db.sync().await.unwrap(); drop(sync_db); @@ -1440,7 +1439,7 @@ where let bounds = target_db.bounds().await; let target_db_op_count = bounds.end; let target_db_inactivity_floor_loc = target_db.inactivity_floor_loc().await; - let sync_lower_bound = target_db.inactivity_floor_loc().await; + let sync_lower_bound = target_db.sync_boundary().await; let sync_upper_bound = bounds.end; let target_hash = target_db.root(); @@ -1467,7 +1466,7 @@ where sync_db.inactivity_floor_loc().await, target_db_inactivity_floor_loc ); - assert_eq!(sync_db.inactivity_floor_loc().await, sync_lower_bound); + assert_eq!(sync_db.sync_boundary().await, sync_lower_bound); // Verify the root digest matches the target (verifies content integrity) assert_eq!(sync_db.root(), target_hash); @@ -1494,11 +1493,11 @@ where source_db = H::apply_ops(source_db, ops).await; // commit already done in apply_ops source_db - .prune(source_db.inactivity_floor_loc().await) + .prune(source_db.sync_boundary().await) .await .unwrap(); - let lower_bound = source_db.inactivity_floor_loc().await; + let lower_bound = source_db.sync_boundary().await; let upper_bound = source_db.bounds().await.end; // Get pinned nodes and target hash before deconstructing source_db @@ -1526,7 +1525,7 @@ where // Verify database state assert_eq!(db.bounds().await.end, target_op_count); assert_eq!(db.inactivity_floor_loc().await, target_inactivity_floor); - assert_eq!(db.inactivity_floor_loc().await, lower_bound); + assert_eq!(db.sync_boundary().await, lower_bound); // Verify the root digest matches the target assert_eq!(db.root(), target_hash); @@ -1651,12 +1650,12 @@ where let ops = H::create_ops(20); target_db = H::apply_ops(target_db, ops).await; target_db - .prune(target_db.inactivity_floor_loc().await) + .prune(target_db.sync_boundary().await) .await .unwrap(); let sync_root = H::sync_target_root(&target_db); - let lower_bound = target_db.inactivity_floor_loc().await; + let lower_bound = target_db.sync_boundary().await; let upper_bound = target_db.bounds().await.end; let db_config = H::config(&context.next_u64().to_string(), &context); @@ -1789,7 +1788,7 @@ where loop { target_db = H::apply_ops(target_db, H::create_ops_seeded(32, seed)).await; target_db - .prune(target_db.inactivity_floor_loc().await) + .prune(target_db.sync_boundary().await) .await .unwrap(); diff --git a/storage/src/qmdb/any/traits.rs b/storage/src/qmdb/any/traits.rs index e947769d56c..d2058d7cc16 100644 --- a/storage/src/qmdb/any/traits.rs +++ b/storage/src/qmdb/any/traits.rs @@ -121,6 +121,10 @@ pub trait DbAny: /// The location before which all operations can be pruned. fn inactivity_floor_loc(&self) -> impl Future> + Send; + + /// The maximum location that [`Self::prune`] accepts and the most recent location from which + /// this database can be safely synced. + fn sync_boundary(&self) -> impl Future> + Send; } /// Proof generation for Any database variants. @@ -208,6 +212,10 @@ macro_rules! impl_db_any { async fn inactivity_floor_loc(&self) -> $crate::merkle::Location<$fam> { self.inactivity_floor_loc() } + + async fn sync_boundary(&self) -> $crate::merkle::Location<$fam> { + self.sync_boundary() + } } }; } diff --git a/storage/src/qmdb/benches/generate.rs b/storage/src/qmdb/benches/generate.rs index bdef1fd21cd..c9528541c02 100644 --- a/storage/src/qmdb/benches/generate.rs +++ b/storage/src/qmdb/benches/generate.rs @@ -38,7 +38,7 @@ async fn bench_db C::Value, ) { gen_random_kv(db, elements, operations, Some(COMMIT_FREQUENCY), make_value).await; - db.prune(db.inactivity_floor_loc().await).await.unwrap(); + db.prune(db.sync_boundary().await).await.unwrap(); db.sync().await.unwrap(); } diff --git a/storage/src/qmdb/current/db.rs b/storage/src/qmdb/current/db.rs index a5f0d42d7a3..2aed1359e73 100644 --- a/storage/src/qmdb/current/db.rs +++ b/storage/src/qmdb/current/db.rs @@ -101,10 +101,8 @@ where Operation: Codec, { /// Return the inactivity floor location. This is the location before which all operations are - /// known to be inactive. Operations before this point can be safely pruned. - /// - /// This is an implementation detail of the activity tracking; external callers should - /// prefer [`Self::sync_boundary`] when constructing a sync target or calling [`Self::prune`]. + /// known to be inactive. + #[cfg(any(test, feature = "test-traits"))] pub(crate) const fn inactivity_floor_loc(&self) -> Location { self.any.inactivity_floor_loc() } @@ -285,52 +283,15 @@ where self.any.pinned_nodes_at(loc).await } - /// Returns the most recent location from which this database can safely be synced. + /// Returns the most recent location from which this database can safely be synced, and the + /// upper bound on [`Self::prune`]'s `prune_loc`. /// /// Callers constructing a sync [`Target`](crate::qmdb::sync::Target) may use this value, or /// any earlier retained location, as `range.start`. Values *above* this boundary are unsafe: - /// the receiver's grafted-pin derivation requires chunk-aligned, absorbed state at the start - /// of the range, and locations above this boundary place that derivation in the - /// delayed-merge-unstable region (relevant for MMB). - pub fn sync_boundary(&self) -> Result, Error> { - self.settled_bitmap_prune_loc() - } - - /// For the youngest of `pruned_chunks` chunks, return the `peak_birth_size` of its - /// chunk-pair parent at height `gh+1`. Returns `None` for families without delayed merges - /// (where `peak_birth_size` at height `gh` equals the chunk boundary). - fn pair_absorption_threshold(pruned_chunks: u64) -> Result, Error> { - if pruned_chunks == 0 { - return Ok(None); - } - - let grafting_height = grafting::height::(); - let youngest = pruned_chunks - 1; - let youngest_start = youngest - .checked_shl(grafting_height) - .ok_or(Error::DataCorrupted("chunk start overflow"))?; - let youngest_end = (youngest + 1) - .checked_shl(grafting_height) - .ok_or(Error::DataCorrupted("chunk end overflow"))?; - let youngest_pos = - F::subtree_root_position(Location::::new(youngest_start), grafting_height); - - // Families without delayed merges: birth_size == chunk_end. - if F::peak_birth_size(youngest_pos, grafting_height) <= youngest_end { - return Ok(None); - } - - let pair_chunk = youngest & !1; - let pair_start = pair_chunk - .checked_shl(grafting_height) - .ok_or(Error::DataCorrupted("pair start overflow"))?; - let pair_pos = - F::subtree_root_position(Location::::new(pair_start), grafting_height + 1); - Ok(Some(F::peak_birth_size(pair_pos, grafting_height + 1))) - } - - /// Returns the most aggressive bitmap prune boundary that is safe for the current ops - /// tree size, accounting for delayed-merge settlement. + /// the receiver's grafted-pin derivation requires absorption-settled state for every fully + /// pruned chunk, which this value guarantees. + /// + /// # Computation /// /// Starts from the inactivity floor (the most chunks we could possibly prune) and walks /// backward until two conditions hold for the youngest chunk that would be pruned: @@ -347,21 +308,19 @@ where /// too. In the worst case the loop decrements twice (once past the unsettled chunk, once /// to land on the older pair boundary). /// - /// For families without delayed merges (e.g. MMR), `peak_birth_size` at height `gh` - /// equals the chunk's last leaf, so condition (1) always holds and the function returns - /// the inactivity floor unchanged. - fn settled_bitmap_prune_loc(&self) -> Result, Error> { + /// For families without delayed merges (e.g. MMR), `peak_birth_size` at height `gh` equals + /// the chunk's last leaf, so condition (1) always holds and the function returns the + /// inactivity floor rounded down to the nearest chunk boundary. + pub fn sync_boundary(&self) -> Location { let chunk_bits = BitMap::::CHUNK_SIZE_BITS; let mut pruned_chunks = *self.any.inactivity_floor_loc / chunk_bits; - let ops_leaves = (*self.any.last_commit_loc) - .checked_add(1) - .ok_or(Error::DataCorrupted("ops size overflow"))?; + let ops_leaves = *self.any.last_commit_loc + 1; let grafting_height = grafting::height::(); while pruned_chunks > 0 { let required_ops = - Self::pair_absorption_threshold(pruned_chunks)?.unwrap_or_else(|| { + Self::pair_absorption_threshold(pruned_chunks).unwrap_or_else(|| { let youngest_start = (pruned_chunks - 1) * chunk_bits; let pos = F::subtree_root_position( Location::::new(youngest_start), @@ -376,23 +335,47 @@ where pruned_chunks -= 1; } - let settled_bits = pruned_chunks - .checked_mul(chunk_bits) - .ok_or(Error::DataCorrupted("bitmap prune boundary overflow"))?; - Ok(Location::new(settled_bits)) + Location::new(pruned_chunks * chunk_bits) + } + + /// For the youngest of `pruned_chunks` chunks, return the `peak_birth_size` of its + /// chunk-pair parent at height `gh+1`. Returns `None` for families without delayed merges + /// (where `peak_birth_size` at height `gh` equals the chunk boundary). + fn pair_absorption_threshold(pruned_chunks: u64) -> Option { + if pruned_chunks == 0 { + return None; + } + + let grafting_height = grafting::height::(); + let youngest = pruned_chunks - 1; + let youngest_start = youngest << grafting_height; + let youngest_end = (youngest + 1) << grafting_height; + let youngest_pos = + F::subtree_root_position(Location::::new(youngest_start), grafting_height); + + // Families without delayed merges: birth_size == chunk_end. + if F::peak_birth_size(youngest_pos, grafting_height) <= youngest_end { + return None; + } + + let pair_chunk = youngest & !1; + let pair_start = pair_chunk << grafting_height; + let pair_pos = + F::subtree_root_position(Location::::new(pair_start), grafting_height + 1); + Some(F::peak_birth_size(pair_pos, grafting_height + 1)) } /// Returns the minimum rewind target that keeps delayed-merge grafting queries valid /// for the current bitmap pruning boundary. /// - /// This is the same absorption threshold used by [`Self::settled_bitmap_prune_loc`]: - /// the `peak_birth_size` of the youngest pruned chunk-pair's height-(gh+1) parent. + /// This is the same absorption threshold used by [`Self::sync_boundary`]: the + /// `peak_birth_size` of the youngest pruned chunk-pair's height-(gh+1) parent. /// Rewinding below this size would put the ops tree in a state where the parent has not /// been born, re-exposing individual height-`gh` ops peaks for pruned chunks whose /// grafted leaves are no longer available. /// /// Returns `None` for families without delayed merges. - fn delayed_merge_rewind_floor(&self) -> Result, Error> { + fn delayed_merge_rewind_floor(&self) -> Option { Self::pair_absorption_threshold(self.status.pruned_chunks() as u64) } @@ -411,30 +394,28 @@ where /// Prunes historical operations prior to `prune_loc`. This does not affect the db's root or /// snapshot. /// - /// Pruning is clipped to the settled bitmap boundary (see [`Db::sync_boundary`]): the ops log's - /// lower bound is never advanced past where the grafting overlay has been pruned. The bitmap - /// and grafted tree advance to that same settled boundary regardless of `prune_loc`. + /// `prune_loc` must be at most [`Self::sync_boundary`]: the ops log's lower bound must not + /// advance past the point where the grafting overlay has been pruned. The bitmap and grafted + /// tree advance to the sync boundary regardless of `prune_loc`. /// /// # Errors /// - /// - Returns [Error::PruneBeyondMinRequired] if `prune_loc` > inactivity floor. + /// - Returns [Error::PruneBeyondMinRequired] if `prune_loc` > [`Self::sync_boundary`]. /// - Returns [`crate::merkle::Error::LocationOverflow`] if `prune_loc` > /// [crate::merkle::Family::MAX_LEAVES]. pub async fn prune(&mut self, prune_loc: Location) -> Result<(), Error> { - let inactivity_floor = self.inactivity_floor_loc(); - if prune_loc > inactivity_floor { - return Err(Error::PruneBeyondMinRequired(prune_loc, inactivity_floor)); + let sync_boundary = self.sync_boundary(); + if prune_loc > sync_boundary { + return Err(Error::PruneBeyondMinRequired(prune_loc, sync_boundary)); } self.flatten(); - // Prune bitmap chunks below the inactivity floor, but for delayed-merge families only - // advance to a rewind-safe settled boundary. - let settled_bitmap_floor = self.settled_bitmap_prune_loc()?; + // Prune bitmap chunks to the sync boundary (most aggressive safe location). let BitmapBatch::::Base(base) = &mut self.status else { unreachable!("flatten() guarantees Base"); }; - Arc::make_mut(base).prune_to_bit(*settled_bitmap_floor); + Arc::make_mut(base).prune_to_bit(*sync_boundary); // Prune the grafted tree to match the bitmap's pruned chunks. let pruned_chunks = self.status.pruned_chunks() as u64; @@ -476,7 +457,7 @@ where // a pruned log with stale metadata would lose peak digests permanently. self.sync_metadata().await?; - self.any.prune(prune_loc.min(settled_bitmap_floor)).await + self.any.prune(prune_loc).await } /// Rewind the database to `size` operations, where `size` is the location of the next append. @@ -518,7 +499,7 @@ where if rewind_size < pruned_bits { return Err(Error::Journal(JournalError::ItemPruned(rewind_size - 1))); } - if let Some(rewind_floor) = self.delayed_merge_rewind_floor()? { + if let Some(rewind_floor) = self.delayed_merge_rewind_floor() { if rewind_size < rewind_floor { return Err(Error::Journal(JournalError::ItemPruned(rewind_size - 1))); } diff --git a/storage/src/qmdb/current/grafting.rs b/storage/src/qmdb/current/grafting.rs index 384ce14c96d..d4c6f8395b8 100644 --- a/storage/src/qmdb/current/grafting.rs +++ b/storage/src/qmdb/current/grafting.rs @@ -436,8 +436,7 @@ impl< /// /// Returns `None` at height 0 (a grafted leaf), since leaves encode bitmap data and /// cannot be recomputed from the tree structure alone. The settlement guard in - /// [`super::db::Db::settled_bitmap_prune_loc`] ensures this case is unreachable for - /// pruned chunks. + /// [`super::db::Db::sync_boundary`] ensures this case is unreachable for pruned chunks. fn reconstruct_grafted_node(&self, pos: Position) -> Option { if let Some(node) = self.grafted_tree.get_node(pos) { return Some(node); diff --git a/storage/src/qmdb/current/mod.rs b/storage/src/qmdb/current/mod.rs index 5f6dc58bc06..11a7b85c38b 100644 --- a/storage/src/qmdb/current/mod.rs +++ b/storage/src/qmdb/current/mod.rs @@ -649,8 +649,7 @@ pub mod tests { commit_writes(&mut db, []).await.unwrap(); let committed_root = db.root(); let committed_op_count = db.bounds().await.end; - let committed_inactivity_floor = db.inactivity_floor_loc().await; - db.prune(committed_inactivity_floor).await.unwrap(); + db.prune(db.sync_boundary().await).await.unwrap(); // Perform more random operations without committing any of them. let db = apply_random_ops::(ELEMENTS, false, rng_seed + 1, db) @@ -691,7 +690,7 @@ pub mod tests { db = apply_random_ops::(ELEMENTS, true, rng_seed + 1, db) .await .unwrap(); - db.prune(db.inactivity_floor_loc().await).await.unwrap(); + db.prune(db.sync_boundary().await).await.unwrap(); // State from scenario #2 should match that of a successful commit. assert_eq!(db.bounds().await.end, committed_op_count); assert_eq!(db.root(), scenario_2_root); @@ -745,7 +744,7 @@ pub mod tests { .await .unwrap(); db_pruning - .prune(db_no_pruning.inactivity_floor_loc().await) + .prune(db_no_pruning.sync_boundary().await) .await .unwrap(); } @@ -804,8 +803,7 @@ pub mod tests { db.apply_batch(merkleized).await.unwrap(); // Prune to flatten bitmap layers and advance pruned_chunks. - let floor = db.inactivity_floor_loc().await; - db.prune(floor).await.unwrap(); + db.prune(db.sync_boundary().await).await.unwrap(); let pruned_bits_before = db.pruned_bits(); warn!( @@ -908,7 +906,7 @@ pub mod tests { // Sync and prune. db.sync().await.unwrap(); - db.prune(db.inactivity_floor_loc().await).await.unwrap(); + db.prune(db.sync_boundary().await).await.unwrap(); // Record root before dropping. let root = db.root(); @@ -1639,8 +1637,8 @@ pub mod tests { }); } - /// Verify that the delayed-merge settlement guard defers bitmap pruning while reopen/proof - /// paths remain stable. + /// Verify that the delayed-merge settlement guard holds `sync_boundary` at 0 during the + /// unsettled window, so `prune` rejects any non-zero `prune_loc`. #[test_traced("INFO")] fn test_current_mmb_settlement_guard_defers_pruning() { let executor = deterministic::Runner::default(); @@ -1672,13 +1670,23 @@ pub mod tests { *db.inactivity_floor_loc() >= 256, "expected inactivity floor past chunk 0" ); + assert_eq!( + *db.sync_boundary(), + 0, + "settlement guard should hold boundary at 0 during unsettled window" + ); - db.prune(Location::::new(1)).await.unwrap(); + // `prune` must reject any non-zero loc because sync_boundary is still 0. + let result = db.prune(Location::::new(1)).await; + assert!( + matches!(result, Err(Error::PruneBeyondMinRequired(_, _))), + "expected PruneBeyondMinRequired, got {result:?}" + ); assert_eq!(db.pruned_bits(), 0); db.sync().await.unwrap(); drop(db); - // Reopen: settlement guard deferred pruning, so state is unchanged. + // Reopen: no pruning occurred, state is unchanged. let reopened: UnorderedVariableMmbDb = UnorderedVariableMmbDb::init( context.with_label("reopen"), variable_config::(partition, &context), @@ -1724,7 +1732,7 @@ pub mod tests { history.push((db.bounds().await.end, db.inactivity_floor_loc())); } - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); let pruned_bits = db.pruned_bits(); assert!(pruned_bits > 0, "expected MMB bitmap pruning to be active"); db.sync().await.unwrap(); @@ -1781,10 +1789,10 @@ pub mod tests { /// Verify that `Db::prune` never advances the ops journal past the settled bitmap /// pruning boundary on a delayed-merge (MMB) family. The journal's lower bound must be - /// less than or equal to `pruning_boundary()`, and the test setup must force the lag to + /// less than or equal to `sync_boundary()`, and the test setup must force the lag to /// be strictly active so the assertion is not vacuous. #[test_traced] - fn test_current_mmb_prune_clips_journal_to_settled_boundary() { + fn test_current_mmb_prune_respects_sync_boundary() { let executor = deterministic::Runner::default(); executor.start(|context| async move { const COMMITS: u64 = 320; @@ -1802,9 +1810,9 @@ pub mod tests { mmb_commit(&mut db, [(k, Some(val(70_000 + round)))]).await; } - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); - let boundary = db.sync_boundary().unwrap(); + let boundary = db.sync_boundary(); let floor = db.inactivity_floor_loc(); assert!( boundary < floor, @@ -1821,7 +1829,7 @@ pub mod tests { }); } - /// Verify that on a non-delayed-merge (MMR) family `pruning_boundary()` lags the inactivity + /// Verify that on a non-delayed-merge (MMR) family `sync_boundary()` lags the inactivity /// floor only by chunk alignment (less than one chunk) — never by a delayed-merge absorption /// window. Guards against an accidental regression that would introduce a larger lag on /// families that don't need it. @@ -1849,9 +1857,9 @@ pub mod tests { .await; } - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); - let boundary = db.sync_boundary().unwrap(); + let boundary = db.sync_boundary(); let floor = db.inactivity_floor_loc(); let chunk_bits = commonware_utils::bitmap::BitMap::::CHUNK_SIZE_BITS; assert!( @@ -1860,7 +1868,7 @@ pub mod tests { ); assert!( db.bounds().await.start <= boundary, - "ops journal bounds must be <= pruning_boundary: bounds.start={}, boundary={boundary}", + "ops journal bounds must be <= sync_boundary: bounds.start={}, boundary={boundary}", db.bounds().await.start ); @@ -1868,7 +1876,7 @@ pub mod tests { }); } - /// Verify that `prune(loc)` with `loc < pruning_boundary()` prunes the ops journal only as far + /// Verify that `prune(loc)` with `loc < sync_boundary()` prunes the ops journal only as far /// as the caller requested. #[test_traced] fn test_current_prune_below_settled_boundary_is_honored() { @@ -1923,7 +1931,7 @@ pub mod tests { mmb_commit(&mut db, [(k, Some(val(60_000 + round)))]).await; } - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); db.sync().await.unwrap(); // Keep growing without pruning: delayed merges now occur in the pruned region. @@ -1992,7 +2000,7 @@ pub mod tests { expected = Some(val(60_000 + round)); mmb_commit(&mut db, [(k, expected)]).await; round += 1; - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); if db.pruned_bits() >= 512 { break; } @@ -2041,7 +2049,7 @@ pub mod tests { mmb_commit(&mut db, [(k, expected)]).await; } - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); db.sync().await.unwrap(); let root_before = db.root(); @@ -2096,7 +2104,7 @@ pub mod tests { commit_idx += 1; } - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); db.sync().await.unwrap(); assert_eq!( db.root(), @@ -2172,7 +2180,7 @@ pub mod tests { "root mismatch before prune at round {round}" ); - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); db.sync().await.unwrap(); assert_eq!( @@ -2235,14 +2243,14 @@ pub mod tests { }); } - /// Verify that prune beyond the inactivity floor is rejected without mutating state. + /// Verify that prune beyond the sync boundary is rejected without mutating state. #[test_traced] - fn test_current_prune_rejects_beyond_inactivity_floor_without_mutation() { + fn test_current_prune_rejects_beyond_sync_boundary_without_mutation() { let executor = deterministic::Runner::default(); executor.start(|context| async move { const COMMITS: u64 = 160; - let partition = "current-prune-beyond-floor"; + let partition = "current-prune-beyond-boundary"; let ctx = context.with_label("db"); let mut db: UnorderedVariableDb = UnorderedVariableDb::init(ctx.clone(), variable_config::(partition, &ctx)) @@ -2257,17 +2265,17 @@ pub mod tests { let expected_root = db.root(); let expected_ops_root = db.ops_root(); - let expected_floor = db.inactivity_floor_loc(); + let expected_boundary = db.sync_boundary(); let expected_pruned_bits = db.pruned_bits(); let expected_value = db.get(&key0).await.unwrap(); // 32 * 8 = 256 bits per chunk for N=32. - let invalid_prune_loc = Location::new(*expected_floor + 256); + let invalid_prune_loc = Location::new(*expected_boundary + 256); let result = db.prune(invalid_prune_loc).await; assert!( - matches!(result, Err(Error::PruneBeyondMinRequired(loc, floor)) - if loc == invalid_prune_loc && floor == expected_floor), - "expected prune rejection above inactivity floor, got {result:?}" + matches!(result, Err(Error::PruneBeyondMinRequired(loc, boundary)) + if loc == invalid_prune_loc && boundary == expected_boundary), + "expected prune rejection above sync boundary, got {result:?}" ); assert_eq!(db.root(), expected_root); @@ -2390,7 +2398,7 @@ pub mod tests { ) .await; - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); let pruned_bits = db.pruned_bits(); assert!( pruned_bits > *first_range.start, @@ -3305,7 +3313,7 @@ pub mod tests { let floor = *db.inactivity_floor_loc(); assert!(floor >= 256, "floor must be past chunk 0: floor={floor}",); - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); db.apply_batch(c_m).await.unwrap(); db.flatten(); diff --git a/storage/src/qmdb/current/ordered/fixed.rs b/storage/src/qmdb/current/ordered/fixed.rs index 25b74af7d7b..4686122acef 100644 --- a/storage/src/qmdb/current/ordered/fixed.rs +++ b/storage/src/qmdb/current/ordered/fixed.rs @@ -159,8 +159,7 @@ pub mod test { } // Prune the database - let floor = db.any.inactivity_floor_loc; - db.prune(floor).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); assert!( db.status.pruned_chunks() > 0, diff --git a/storage/src/qmdb/current/sync/mod.rs b/storage/src/qmdb/current/sync/mod.rs index 9107d03a486..a1cd1fd3741 100644 --- a/storage/src/qmdb/current/sync/mod.rs +++ b/storage/src/qmdb/current/sync/mod.rs @@ -174,9 +174,9 @@ where // `popcount(pruned_chunks)` are at or above the grafting height. The remaining // smaller peaks cover the partial trailing chunk and are not grafted pinned nodes. // - // This relies on the pruning-boundary invariant: at `range.end`, every pruned chunk's - // height-`gh` subtree is absorbed, so `nodes_to_pin` at a chunk-aligned `range.start` - // returns the correct positions for both MMR and MMB. + // Requires `range.start <=` target's [`Db::sync_boundary`](db::Db::sync_boundary): that + // bound guarantees every fully-pruned chunk's height-`gh` subtree is absorbed at + // `range.end`, so `nodes_to_pin` returns the correct positions for both MMR and MMB. let grafted_pinned_nodes = { let ops_pin_positions: Vec<_> = F::nodes_to_pin(range.start()).collect(); let num_grafted_pins = (pruned_chunks as u64).count_ones() as usize; diff --git a/storage/src/qmdb/current/sync/tests.rs b/storage/src/qmdb/current/sync/tests.rs index b6fcb662d06..a43d614bc35 100644 --- a/storage/src/qmdb/current/sync/tests.rs +++ b/storage/src/qmdb/current/sync/tests.rs @@ -265,11 +265,11 @@ mod harnesses { db } - pub struct UnorderedFixedMmrHarness; + pub struct UnorderedFixedHarness(std::marker::PhantomData); - impl SyncTestHarness for UnorderedFixedMmrHarness { - type Family = mmr::Family; - type Db = UnorderedFixedDb; + impl SyncTestHarness for UnorderedFixedHarness { + type Family = F; + type Db = UnorderedFixedDb; fn sync_target_root(db: &Self::Db) -> Digest { SyncDatabase::root(db) @@ -281,17 +281,15 @@ mod harnesses { fn create_ops( n: usize, - ) -> Vec> - { - create_unordered_fixed_ops::(n, 0) + ) -> Vec> { + create_unordered_fixed_ops::(n, 0) } fn create_ops_seeded( n: usize, seed: u64, - ) -> Vec> - { - create_unordered_fixed_ops::(n, seed) + ) -> Vec> { + create_unordered_fixed_ops::(n, seed) } async fn init_db(ctx: Context) -> Self::Db { @@ -305,63 +303,20 @@ mod harnesses { async fn apply_ops( db: Self::Db, - ops: Vec>, + ops: Vec>, ) -> Self::Db { apply_unordered_fixed_ops(db, ops).await } } - pub struct UnorderedFixedMmbHarness; + pub type UnorderedFixedMmrHarness = UnorderedFixedHarness; + pub type UnorderedFixedMmbHarness = UnorderedFixedHarness; - impl SyncTestHarness for UnorderedFixedMmbHarness { - type Family = mmb::Family; - type Db = UnorderedFixedDb; + pub struct UnorderedVariableHarness(std::marker::PhantomData); - fn sync_target_root(db: &Self::Db) -> Digest { - SyncDatabase::root(db) - } - - fn config(suffix: &str, pooler: &impl BufferPooler) -> ConfigOf { - fixed_config::(suffix, pooler) - } - - fn create_ops( - n: usize, - ) -> Vec> - { - create_unordered_fixed_ops::(n, 0) - } - - fn create_ops_seeded( - n: usize, - seed: u64, - ) -> Vec> - { - create_unordered_fixed_ops::(n, seed) - } - - async fn init_db(ctx: Context) -> Self::Db { - let cfg = fixed_config::("default", &ctx); - Self::Db::init(ctx, cfg).await.unwrap() - } - - async fn init_db_with_config(ctx: Context, config: ConfigOf) -> Self::Db { - Self::Db::init(ctx, config).await.unwrap() - } - - async fn apply_ops( - db: Self::Db, - ops: Vec>, - ) -> Self::Db { - apply_unordered_fixed_ops(db, ops).await - } - } - - pub struct UnorderedVariableMmrHarness; - - impl SyncTestHarness for UnorderedVariableMmrHarness { - type Family = mmr::Family; - type Db = UnorderedVariableDb; + impl SyncTestHarness for UnorderedVariableHarness { + type Family = F; + type Db = UnorderedVariableDb; fn sync_target_root(db: &Self::Db) -> Digest { SyncDatabase::root(db) @@ -373,17 +328,15 @@ mod harnesses { fn create_ops( n: usize, - ) -> Vec> - { - create_unordered_variable_ops::(n, 0) + ) -> Vec> { + create_unordered_variable_ops::(n, 0) } fn create_ops_seeded( n: usize, seed: u64, - ) -> Vec> - { - create_unordered_variable_ops::(n, seed) + ) -> Vec> { + create_unordered_variable_ops::(n, seed) } async fn init_db(ctx: Context) -> Self::Db { @@ -397,63 +350,20 @@ mod harnesses { async fn apply_ops( db: Self::Db, - ops: Vec>, + ops: Vec>, ) -> Self::Db { apply_unordered_variable_ops(db, ops).await } } - pub struct UnorderedVariableMmbHarness; + pub type UnorderedVariableMmrHarness = UnorderedVariableHarness; + pub type UnorderedVariableMmbHarness = UnorderedVariableHarness; - impl SyncTestHarness for UnorderedVariableMmbHarness { - type Family = mmb::Family; - type Db = UnorderedVariableDb; + pub struct OrderedFixedHarness(std::marker::PhantomData); - fn sync_target_root(db: &Self::Db) -> Digest { - SyncDatabase::root(db) - } - - fn config(suffix: &str, pooler: &impl BufferPooler) -> ConfigOf { - variable_config::(suffix, pooler) - } - - fn create_ops( - n: usize, - ) -> Vec> - { - create_unordered_variable_ops::(n, 0) - } - - fn create_ops_seeded( - n: usize, - seed: u64, - ) -> Vec> - { - create_unordered_variable_ops::(n, seed) - } - - async fn init_db(ctx: Context) -> Self::Db { - let cfg = variable_config::("default", &ctx); - Self::Db::init(ctx, cfg).await.unwrap() - } - - async fn init_db_with_config(ctx: Context, config: ConfigOf) -> Self::Db { - Self::Db::init(ctx, config).await.unwrap() - } - - async fn apply_ops( - db: Self::Db, - ops: Vec>, - ) -> Self::Db { - apply_unordered_variable_ops(db, ops).await - } - } - - pub struct OrderedFixedMmrHarness; - - impl SyncTestHarness for OrderedFixedMmrHarness { - type Family = mmr::Family; - type Db = OrderedFixedDb; + impl SyncTestHarness for OrderedFixedHarness { + type Family = F; + type Db = OrderedFixedDb; fn sync_target_root(db: &Self::Db) -> Digest { SyncDatabase::root(db) @@ -465,15 +375,15 @@ mod harnesses { fn create_ops( n: usize, - ) -> Vec> { - create_ordered_fixed_ops::(n, 0) + ) -> Vec> { + create_ordered_fixed_ops::(n, 0) } fn create_ops_seeded( n: usize, seed: u64, - ) -> Vec> { - create_ordered_fixed_ops::(n, seed) + ) -> Vec> { + create_ordered_fixed_ops::(n, seed) } async fn init_db(ctx: Context) -> Self::Db { @@ -487,61 +397,20 @@ mod harnesses { async fn apply_ops( db: Self::Db, - ops: Vec>, + ops: Vec>, ) -> Self::Db { apply_ordered_fixed_ops(db, ops).await } } - pub struct OrderedFixedMmbHarness; - - impl SyncTestHarness for OrderedFixedMmbHarness { - type Family = mmb::Family; - type Db = OrderedFixedDb; + pub type OrderedFixedMmrHarness = OrderedFixedHarness; + pub type OrderedFixedMmbHarness = OrderedFixedHarness; - fn sync_target_root(db: &Self::Db) -> Digest { - SyncDatabase::root(db) - } - - fn config(suffix: &str, pooler: &impl BufferPooler) -> ConfigOf { - fixed_config::(suffix, pooler) - } - - fn create_ops( - n: usize, - ) -> Vec> { - create_ordered_fixed_ops::(n, 0) - } + pub struct OrderedVariableHarness(std::marker::PhantomData); - fn create_ops_seeded( - n: usize, - seed: u64, - ) -> Vec> { - create_ordered_fixed_ops::(n, seed) - } - - async fn init_db(ctx: Context) -> Self::Db { - let cfg = fixed_config::("default", &ctx); - Self::Db::init(ctx, cfg).await.unwrap() - } - - async fn init_db_with_config(ctx: Context, config: ConfigOf) -> Self::Db { - Self::Db::init(ctx, config).await.unwrap() - } - - async fn apply_ops( - db: Self::Db, - ops: Vec>, - ) -> Self::Db { - apply_ordered_fixed_ops(db, ops).await - } - } - - pub struct OrderedVariableMmrHarness; - - impl SyncTestHarness for OrderedVariableMmrHarness { - type Family = mmr::Family; - type Db = OrderedVariableDb; + impl SyncTestHarness for OrderedVariableHarness { + type Family = F; + type Db = OrderedVariableDb; fn sync_target_root(db: &Self::Db) -> Digest { SyncDatabase::root(db) @@ -553,17 +422,15 @@ mod harnesses { fn create_ops( n: usize, - ) -> Vec> - { - create_ordered_variable_ops::(n, 0) + ) -> Vec> { + create_ordered_variable_ops::(n, 0) } fn create_ops_seeded( n: usize, seed: u64, - ) -> Vec> - { - create_ordered_variable_ops::(n, seed) + ) -> Vec> { + create_ordered_variable_ops::(n, seed) } async fn init_db(ctx: Context) -> Self::Db { @@ -577,57 +444,14 @@ mod harnesses { async fn apply_ops( db: Self::Db, - ops: Vec>, + ops: Vec>, ) -> Self::Db { apply_ordered_variable_ops(db, ops).await } } - pub struct OrderedVariableMmbHarness; - - impl SyncTestHarness for OrderedVariableMmbHarness { - type Family = mmb::Family; - type Db = OrderedVariableDb; - - fn sync_target_root(db: &Self::Db) -> Digest { - SyncDatabase::root(db) - } - - fn config(suffix: &str, pooler: &impl BufferPooler) -> ConfigOf { - variable_config::(suffix, pooler) - } - - fn create_ops( - n: usize, - ) -> Vec> - { - create_ordered_variable_ops::(n, 0) - } - - fn create_ops_seeded( - n: usize, - seed: u64, - ) -> Vec> - { - create_ordered_variable_ops::(n, seed) - } - - async fn init_db(ctx: Context) -> Self::Db { - let cfg = variable_config::("default", &ctx); - Self::Db::init(ctx, cfg).await.unwrap() - } - - async fn init_db_with_config(ctx: Context, config: ConfigOf) -> Self::Db { - Self::Db::init(ctx, config).await.unwrap() - } - - async fn apply_ops( - db: Self::Db, - ops: Vec>, - ) -> Self::Db { - apply_ordered_variable_ops(db, ops).await - } - } + pub type OrderedVariableMmrHarness = OrderedVariableHarness; + pub type OrderedVariableMmbHarness = OrderedVariableHarness; } /// Regression test: sync a pruned MMB-backed current DB and verify the synced DB has the @@ -681,14 +505,11 @@ fn test_current_mmb_sync_with_pruned_full_chunk_reopens() { "expected inactivity floor past chunk 0" ); - target_db - .prune(target_db.inactivity_floor_loc()) - .await - .unwrap(); + target_db.prune(target_db.sync_boundary()).await.unwrap(); let sync_root = SyncDatabase::root(&target_db); let verification_root = target_db.root(); - let lower_bound = target_db.inactivity_floor_loc(); + let lower_bound = target_db.sync_boundary(); let upper_bound = target_db.bounds().await.end; let client_suffix = context.next_u64().to_string(); @@ -718,7 +539,7 @@ fn test_current_mmb_sync_with_pruned_full_chunk_reopens() { assert_eq!(SyncDatabase::root(&synced_db), sync_root); assert_eq!(synced_db.root(), verification_root); - assert_eq!(synced_db.inactivity_floor_loc(), lower_bound); + assert_eq!(synced_db.sync_boundary(), lower_bound); assert_eq!(synced_db.get(&key).await.unwrap(), expected); drop(synced_db); @@ -728,7 +549,7 @@ fn test_current_mmb_sync_with_pruned_full_chunk_reopens() { .unwrap(); assert_eq!(SyncDatabase::root(&reopened), sync_root); assert_eq!(reopened.root(), verification_root); - assert_eq!(reopened.inactivity_floor_loc(), lower_bound); + assert_eq!(reopened.sync_boundary(), lower_bound); assert_eq!(reopened.get(&key).await.unwrap(), expected); reopened.destroy().await.unwrap(); diff --git a/storage/src/qmdb/sync/database.rs b/storage/src/qmdb/sync/database.rs index 43100e52abe..cdf8af01ccc 100644 --- a/storage/src/qmdb/sync/database.rs +++ b/storage/src/qmdb/sync/database.rs @@ -59,20 +59,12 @@ pub trait Database: Sized + Send { /// Returns whether persisted local state already matches the requested sync target. /// - /// Databases can override this to allow the sync engine to complete immediately - /// when an on-disk database already matches the target and can be rebuilt without - /// fetching fresh boundary pins. - /// - /// # Caller contract - /// - /// `target.range.start()` **must** equal the committed inactivity floor of - /// the target state (i.e. the floor carried by the last `CommitFloor` op). - /// Implementations are free to verify only that the persisted tree size and - /// root match and to skip checking the persisted merkle pruning boundary - /// directly. Callers that set `target.range.start()` below the committed - /// floor (or that prune their own database past the committed floor) can cause - /// a later [`Self::from_sync_result`] rebuild to fail with `MissingNode` even - /// though this function returned `true`. + /// Databases can override this to let the sync engine finish immediately when an + /// on-disk database already reflects the target. Implementations may verify only + /// that persisted tree size and root match; the merkle pruning boundary is not + /// re-checked. Callers are responsible for keeping their local pruning point at or + /// below `target.range.start()`; pruning past it can cause a later + /// [`Self::from_sync_result`] rebuild to fail. fn has_local_target_state( _context: Self::Context, _config: &Self::Config, From 263b0c92721604d769908f341a4af51c65e9c7cc Mon Sep 17 00:00:00 2001 From: Roberto Bayardo Date: Mon, 20 Apr 2026 12:53:52 -0700 Subject: [PATCH 4/8] address review --- examples/sync/README.md | 2 +- storage/src/qmdb/any/mod.rs | 6 +- storage/src/qmdb/any/ordered/fixed.rs | 4 +- storage/src/qmdb/any/ordered/mod.rs | 12 +--- storage/src/qmdb/any/sync/mod.rs | 5 +- storage/src/qmdb/any/sync/tests.rs | 82 +++++++++++++++------------ storage/src/qmdb/current/sync/mod.rs | 30 +++++----- storage/src/qmdb/sync/engine.rs | 8 +-- storage/src/qmdb/sync/resolver.rs | 4 +- 9 files changed, 79 insertions(+), 74 deletions(-) diff --git a/examples/sync/README.md b/examples/sync/README.md index 8c562105498..302be112595 100644 --- a/examples/sync/README.md +++ b/examples/sync/README.md @@ -117,7 +117,7 @@ curl http://localhost:9090/metrics 4. **Sync Iteration Loop**: For each sync iteration: - **Database Initialization**: Client opens a new database (or reopens existing one) - **Connection**: Client establishes connection to server - - **Initial Sync Target**: Client requests server metadata to determine sync target (inactivity floor, size, and root digest) + - **Initial Sync Target**: Client requests server metadata to determine sync target (sync boundary, size, and root digest) - **Dynamic Target Updates**: Client periodically requests target updates during sync to handle new operations added by the server - **Sync Completion**: Client continues until all operations are applied and state matches server's target - **Database Closure**: Client closes the database to prepare for next iteration diff --git a/storage/src/qmdb/any/mod.rs b/storage/src/qmdb/any/mod.rs index 59b1d7f1650..2f669cb5107 100644 --- a/storage/src/qmdb/any/mod.rs +++ b/storage/src/qmdb/any/mod.rs @@ -289,7 +289,7 @@ pub(crate) mod test { db.apply_batch(merkleized).await.unwrap(); } db.commit().await.unwrap(); - db.prune(db.inactivity_floor_loc().await).await.unwrap(); + db.prune(db.sync_boundary().await).await.unwrap(); let root = db.root(); let op_count = db.size().await; let inactivity_floor_loc = db.inactivity_floor_loc().await; @@ -642,7 +642,7 @@ pub(crate) mod test { } // Commit + sync with pruning raises inactivity floor. db.sync().await.unwrap(); - db.prune(db.inactivity_floor_loc().await).await.unwrap(); + db.prune(db.sync_boundary().await).await.unwrap(); // Drop & reopen and ensure state matches. let root = db.root(); @@ -2036,7 +2036,7 @@ pub(crate) mod test { .collect(); commit_writes(&mut db, updates, None).await; - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); let bounds = db.bounds().await; if bounds.start > first_range.start { break; diff --git a/storage/src/qmdb/any/ordered/fixed.rs b/storage/src/qmdb/any/ordered/fixed.rs index 088456f6cf3..efad157322d 100644 --- a/storage/src/qmdb/any/ordered/fixed.rs +++ b/storage/src/qmdb/any/ordered/fixed.rs @@ -427,7 +427,7 @@ pub(crate) mod test { // Test that apply_batch + sync w/ pruning will raise the activity floor. db.sync().await.unwrap(); - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); assert_eq!(db.snapshot.items(), 857); // Drop & reopen the db, making sure it has exactly the same state. @@ -494,7 +494,7 @@ pub(crate) mod test { db.apply_batch(merkleized).await.unwrap(); db.commit().await.unwrap(); } - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); let root = db.root(); let op_count = db.bounds().await.end; let inactivity_floor_loc = db.inactivity_floor_loc(); diff --git a/storage/src/qmdb/any/ordered/mod.rs b/storage/src/qmdb/any/ordered/mod.rs index 8c84b6f4dae..d6409f81563 100644 --- a/storage/src/qmdb/any/ordered/mod.rs +++ b/storage/src/qmdb/any/ordered/mod.rs @@ -335,10 +335,7 @@ mod test { reopen_db: impl Fn(Context) -> Pin + Send>>, ) { assert!(db.get_metadata().await.unwrap().is_none()); - assert!(matches!( - db.prune(db.inactivity_floor_loc().await).await, - Ok(()) - )); + assert!(matches!(db.prune(db.sync_boundary().await).await, Ok(()))); // Make sure closing/reopening gets us back to the same state, even after adding an // uncommitted op, and even without a clean shutdown. @@ -361,10 +358,7 @@ mod test { assert_eq!(range.start, Location::new(1)); assert_eq!(db.get_metadata().await.unwrap(), Some(metadata)); let root = db.root(); - assert!(matches!( - db.prune(db.inactivity_floor_loc().await).await, - Ok(()) - )); + assert!(matches!(db.prune(db.sync_boundary().await).await, Ok(()))); // Re-opening the DB without a clean shutdown should still recover the correct state. let mut db = reopen_db(context.with_label("reopen2")).await; @@ -577,7 +571,7 @@ mod test { // Pruning inactive ops should not affect current state or root. let root = db.root(); - db.prune(db.inactivity_floor_loc().await).await.unwrap(); + db.prune(db.sync_boundary().await).await.unwrap(); assert_eq!(db.root(), root); db.destroy().await.unwrap(); diff --git a/storage/src/qmdb/any/sync/mod.rs b/storage/src/qmdb/any/sync/mod.rs index 4816d30f602..8440e7fde73 100644 --- a/storage/src/qmdb/any/sync/mod.rs +++ b/storage/src/qmdb/any/sync/mod.rs @@ -49,7 +49,7 @@ pub(crate) mod tests; /// Returns whether persisted local state already matches the requested sync target. /// /// Shared across [crate::qmdb::any] and [crate::qmdb::current] sync because both -/// build on the same operations-MMR layout and share the same merkle partition. +/// build on the same operations-tree layout and share the same merkle partition. /// Verifies only that the persisted tree size and root match; the merkle pruning /// boundary is not re-checked. Callers must keep their local pruning point at or /// below `target.range.start()` or a later @@ -71,8 +71,7 @@ where &hasher, ) .await; - // Size + root match implies the last CommitFloor op (and therefore the - // size + root identify a unique state, so if they match the target's we can reuse + // Size + root identify a unique state, so if they match the target's we can reuse // the persisted DB without fetching boundary pins. matches!( peek, diff --git a/storage/src/qmdb/any/sync/tests.rs b/storage/src/qmdb/any/sync/tests.rs index 951a349a4c0..37873a93e8c 100644 --- a/storage/src/qmdb/any/sync/tests.rs +++ b/storage/src/qmdb/any/sync/tests.rs @@ -53,9 +53,6 @@ pub(crate) type ConfigOf = as qmdb::sync::Database>::Config; /// Type alias for the journal type of a harness. pub(crate) type JournalOf = as qmdb::sync::Database>::Journal; -/// Type alias for the merkle family used by a harness. -pub(crate) type FamilyOf = as qmdb::sync::Database>::Family; - /// Trait for cleanup operations in tests. pub(crate) trait Destructible { type Family: merkle::Family; @@ -136,7 +133,7 @@ pub(crate) trait SyncTestHarness: Sized + 'static { /// Test that empty operations arrays fetched do not cause panics when stored and applied pub(crate) fn test_sync_empty_operations_no_panic() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -181,14 +178,14 @@ where /// Test that resolver failure is handled correctly pub(crate) fn test_sync_resolver_fails() where - resolver::tests::FailResolver, OpOf, Digest>: - Resolver, Op = OpOf, Digest = Digest>, + resolver::tests::FailResolver, Digest>: + Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { - let resolver = resolver::tests::FailResolver::, OpOf, Digest>::new(); + let resolver = resolver::tests::FailResolver::, Digest>::new(); let target_root = Digest::from([0; 32]); let db_config = H::config(&context.next_u64().to_string(), &context); @@ -217,7 +214,7 @@ where /// Test basic sync functionality with various batch sizes pub(crate) fn test_sync(target_db_ops: usize, fetch_batch_size: NonZeroU64) where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -300,8 +297,8 @@ where /// Test syncing to a subset of the target database (target has additional ops beyond sync range) pub(crate) fn test_sync_subset_of_target_database(target_db_ops: usize) where - Arc>: Resolver, Op = OpOf, Digest = Digest>, - OpOf: Encode + Clone + OperationTrait, Key = Digest>, + Arc>: Resolver, Digest = Digest>, + OpOf: Encode + Clone + OperationTrait, JournalOf: Contiguous, { let executor = deterministic::Runner::default(); @@ -366,8 +363,8 @@ where /// Tests the scenario where sync_db already has partial data and needs to sync additional ops. pub(crate) fn test_sync_use_existing_db_partial_match(original_ops: usize) where - Arc>: Resolver, Op = OpOf, Digest = Digest>, - OpOf: Encode + Clone + OperationTrait, Key = Digest>, + Arc>: Resolver, Digest = Digest>, + OpOf: Encode + Clone + OperationTrait, JournalOf: Contiguous, { let executor = deterministic::Runner::default(); @@ -460,9 +457,9 @@ where /// Uses FailResolver to verify that no network requests are made since data already exists. pub(crate) fn test_sync_use_existing_db_exact_match(num_ops: usize) where - resolver::tests::FailResolver, OpOf, Digest>: - Resolver, Op = OpOf, Digest = Digest>, - OpOf: Encode + Clone + OperationTrait, Key = Digest>, + resolver::tests::FailResolver, Digest>: + Resolver, Digest = Digest>, + OpOf: Encode + Clone + OperationTrait, JournalOf: Contiguous, { let executor = deterministic::Runner::default(); @@ -501,7 +498,7 @@ where // sync_db should never ask the resolver for operations // because it is already complete. Use a resolver that always fails // to ensure that it's not being used. - let resolver = resolver::tests::FailResolver::, OpOf, Digest>::new(); + let resolver = resolver::tests::FailResolver::, Digest>::new(); let config = Config { db_config: sync_config, // Use same config to access same partitions fetch_batch_size: NZU64!(10), @@ -546,7 +543,7 @@ where /// Test that the client fails to sync if the lower bound is decreased via target update. pub(crate) fn test_target_update_lower_bound_decrease() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -621,7 +618,7 @@ where /// Test that the client fails to sync if the upper bound is decreased via target update. pub(crate) fn test_target_update_upper_bound_decrease() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -690,7 +687,7 @@ where /// Test that the client succeeds when bounds are updated (increased). pub(crate) fn test_target_update_bounds_increase() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode + Clone, JournalOf: Contiguous, { @@ -775,7 +772,7 @@ where /// Test that target updates can be sent even after the client is done (no panic). pub(crate) fn test_target_update_on_done_client() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -844,7 +841,7 @@ where /// Test that explicit finish control waits for a finish signal even after reaching target. pub(crate) fn test_sync_waits_for_explicit_finish() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -947,7 +944,7 @@ where /// Test that a finish signal received before target completion still allows full sync. pub(crate) fn test_sync_handles_early_finish_signal() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -1010,7 +1007,7 @@ where /// Test that dropping finish sender without sending is treated as an error. pub(crate) fn test_sync_fails_when_finish_sender_dropped() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -1059,7 +1056,7 @@ where /// Test that dropping reached-target receiver does not fail sync. pub(crate) fn test_sync_allows_dropped_reached_target_receiver() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -1113,8 +1110,7 @@ pub(crate) fn test_target_update_during_sync( initial_ops: usize, additional_ops: usize, ) where - Arc>>>: - Resolver, Op = OpOf, Digest = Digest>, + Arc>>>: Resolver, Digest = Digest>, OpOf: Encode + Clone, JournalOf: Contiguous, { @@ -1225,7 +1221,7 @@ pub(crate) fn test_target_update_during_sync( /// Test demonstrating that a synced database can be reopened and retain its state. pub(crate) fn test_sync_database_persistence() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode + Clone, JournalOf: Contiguous, { @@ -1299,7 +1295,7 @@ where /// Test post-sync usability: after syncing, the database supports normal operations. pub(crate) fn test_sync_post_sync_usability() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -1639,7 +1635,7 @@ where /// succeeds on retry when the resolver returns correct data. pub(crate) fn test_sync_retries_bad_pinned_nodes() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -1776,7 +1772,7 @@ impl> Resolver for ReplayFreshBoundaryResolver { /// boundary retry is still outstanding. pub(crate) fn test_sync_waits_for_boundary_retry_after_target_update() where - Arc>: Resolver, Op = OpOf, Digest = Digest>, + Arc>: Resolver, Digest = Digest>, OpOf: Encode, JournalOf: Contiguous, { @@ -2349,6 +2345,8 @@ mod harnesses { } let merkleized = batch.merkleize(&db, None::).await.unwrap(); db.apply_batch(merkleized).await.unwrap(); + let merkleized = db.new_batch().merkleize(&db, None::).await.unwrap(); + db.apply_batch(merkleized).await.unwrap(); db } } @@ -2429,6 +2427,12 @@ mod harnesses { } let merkleized = batch.merkleize(&db, None::>).await.unwrap(); db.apply_batch(merkleized).await.unwrap(); + let merkleized = db + .new_batch() + .merkleize(&db, None::>) + .await + .unwrap(); + db.apply_batch(merkleized).await.unwrap(); db } } @@ -2506,6 +2510,8 @@ mod harnesses { } let merkleized = batch.merkleize(&db, None::).await.unwrap(); db.apply_batch(merkleized).await.unwrap(); + let merkleized = db.new_batch().merkleize(&db, None::).await.unwrap(); + db.apply_batch(merkleized).await.unwrap(); db } } @@ -2589,6 +2595,12 @@ mod harnesses { } let merkleized = batch.merkleize(&db, None::>).await.unwrap(); db.apply_batch(merkleized).await.unwrap(); + let merkleized = db + .new_batch() + .merkleize(&db, None::>) + .await + .unwrap(); + db.apply_batch(merkleized).await.unwrap(); db } } @@ -2720,6 +2732,11 @@ macro_rules! sync_tests_for_harness { fn test_sync_retries_bad_pinned_nodes() { super::test_sync_retries_bad_pinned_nodes::<$harness>(); } + + #[test_traced] + fn test_sync_waits_for_boundary_retry_after_target_update() { + super::test_sync_waits_for_boundary_retry_after_target_update::<$harness>(); + } } }; } @@ -2732,11 +2749,6 @@ macro_rules! from_sync_result_tests_for_harness { use super::harnesses; use commonware_macros::test_traced; - #[test_traced] - fn test_sync_waits_for_boundary_retry_after_target_update() { - super::test_sync_waits_for_boundary_retry_after_target_update::<$harness>(); - } - #[test_traced("WARN")] fn test_from_sync_result_empty_to_empty() { super::test_from_sync_result_empty_to_empty::<$harness>(); diff --git a/storage/src/qmdb/current/sync/mod.rs b/storage/src/qmdb/current/sync/mod.rs index a1cd1fd3741..9446fb3367f 100644 --- a/storage/src/qmdb/current/sync/mod.rs +++ b/storage/src/qmdb/current/sync/mod.rs @@ -3,27 +3,27 @@ //! Contains implementation of [crate::qmdb::sync::Database] for all [Db](crate::qmdb::current::db::Db) //! variants (ordered/unordered, fixed/variable). //! -//! The canonical root of a `current` database combines the ops root, grafted MMR root, and +//! The canonical root of a `current` database combines the ops root, grafted tree root, and //! optional partial chunk into a single hash (see the [Root structure](super) section in the //! module documentation). The sync engine operates on the **ops root**, not the canonical root: -//! it downloads operations and verifies each batch against the ops root using standard MMR +//! it downloads operations and verifies each batch against the ops root using standard merkle //! range proofs (identical to `any` sync). [crate::qmdb::current::proof::OpsRootWitness] can be //! used by callers that need to authenticate the synced ops root against a trusted canonical root; //! the sync engine does not perform this check itself. //! -//! After all operations are synced, the bitmap and grafted MMR are reconstructed +//! After all operations are synced, the bitmap and grafted tree are reconstructed //! deterministically from the operations. The canonical root is then computed from the -//! ops root, the reconstructed grafted MMR root, and any partial chunk. +//! ops root, the reconstructed grafted tree root, and any partial chunk. //! //! The [Database]`::`[root()](crate::qmdb::sync::Database::root) //! implementation returns the **ops root** (not the canonical root) because that is what the //! sync engine verifies against. //! -//! For pruned databases (`range.start > 0`), grafted MMR pinned nodes for the pruned region -//! are read directly from the ops MMR after it is built. This works because of the zero-chunk +//! For pruned databases (`range.start > 0`), grafted pinned nodes for the pruned region are +//! read directly from the ops tree after it is built. This works because of the zero-chunk //! identity: for all-zero bitmap chunks (which all pruned chunks are), the grafted leaf equals -//! the ops subtree root, making the grafted MMR structurally identical to the ops MMR at and -//! above the grafting height. +//! the ops subtree root, making the grafted tree structurally identical to the ops tree at +//! and above the grafting height. use crate::{ index::Factory as IndexFactory, @@ -89,8 +89,8 @@ impl Config for super::Config { /// /// This follows the same pattern as `any/sync/mod.rs::build_db` but additionally: /// * Builds the activity bitmap by replaying the operations log. -/// * Extracts grafted pinned nodes from the ops MMR (zero-chunk identity). -/// * Builds the grafted MMR from the bitmap and ops MMR. +/// * Extracts grafted pinned nodes from the ops tree (zero-chunk identity). +/// * Builds the grafted tree from the bitmap and ops tree. /// * Computes and caches the canonical root. #[allow(clippy::too_many_arguments)] async fn build_db( @@ -163,11 +163,11 @@ where ) .await?; - // Extract grafted pinned nodes from the ops MMR. + // Extract grafted pinned nodes from the ops tree. // // With the zero-chunk identity, all-zero bitmap chunks (which all pruned chunks are) // produce grafted leaves equal to the corresponding ops subtree root. The grafted - // MMR's pinned nodes for the pruned region are therefore the first + // tree's pinned nodes for the pruned region are therefore the first // `popcount(pruned_chunks)` ops pinned nodes (in decreasing height order). // // `nodes_to_pin(range.start)` returns all ops peaks, but only the first @@ -176,7 +176,7 @@ where // // Requires `range.start <=` target's [`Db::sync_boundary`](db::Db::sync_boundary): that // bound guarantees every fully-pruned chunk's height-`gh` subtree is absorbed at - // `range.end`, so `nodes_to_pin` returns the correct positions for both MMR and MMB. + // `range.end`, so `nodes_to_pin` returns the correct positions for any merkle family. let grafted_pinned_nodes = { let ops_pin_positions: Vec<_> = F::nodes_to_pin(range.start()).collect(); let num_grafted_pins = (pruned_chunks as u64).count_ones() as usize; @@ -193,7 +193,7 @@ where pins }; - // Build grafted MMR. + // Build grafted tree. let hasher = StandardHasher::::new(); let grafted_tree = db::build_grafted_tree::( &hasher, @@ -312,7 +312,7 @@ macro_rules! impl_current_sync_database { } /// Returns the ops root (not the canonical root), since the sync engine verifies - /// batches against the ops MMR. + /// batches against the ops tree. fn root(&self) -> Self::Digest { self.any.log.root() } diff --git a/storage/src/qmdb/sync/engine.rs b/storage/src/qmdb/sync/engine.rs index 105f9625983..e0648c251eb 100644 --- a/storage/src/qmdb/sync/engine.rs +++ b/storage/src/qmdb/sync/engine.rs @@ -166,7 +166,7 @@ where /// The vectors in the map are non-empty. fetched_operations: BTreeMap, Vec>, - /// Pinned MMR nodes extracted from proofs, used for database construction + /// Pinned merkle nodes extracted from proofs, used for database construction pinned_nodes: Option>, /// Whether persisted local state already matches the current target and can be @@ -175,9 +175,9 @@ where /// Historical roots from previous sync targets, keyed by tree size /// (target.range.end()). Each tree size maps to a unique root because - /// the MMR is append-only and validate_update rejects unchanged roots. - /// When a retained request completes, proof.leaves identifies which - /// historical root to verify against. + /// the merkle tree is append-only and validate_update rejects unchanged + /// roots. When a retained request completes, proof.leaves identifies + /// which historical root to verify against. retained_roots: HashMap, DB::Digest>, /// Tree sizes of retained roots in insertion order (oldest first), diff --git a/storage/src/qmdb/sync/resolver.rs b/storage/src/qmdb/sync/resolver.rs index c8e9302217f..4dc9c6e8482 100644 --- a/storage/src/qmdb/sync/resolver.rs +++ b/storage/src/qmdb/sync/resolver.rs @@ -38,7 +38,7 @@ pub struct FetchResult { pub operations: Vec, /// Channel to report success/failure back to resolver pub success_tx: oneshot::Sender, - /// Pinned MMR nodes at the start location, if requested + /// Pinned merkle nodes at the start location, if requested pub pinned_nodes: Option>, } @@ -70,7 +70,7 @@ pub trait Resolver: Send + Sync + Clone + 'static { /// Get the operations starting at `start_loc` in the database, up to `max_ops` operations. /// Returns the operations and a proof that they were present in the database when it had /// `op_count` operations. If `include_pinned_nodes` is true, the result will include the - /// pinned MMR nodes at `start_loc`. + /// pinned merkle nodes at `start_loc`. /// /// The corresponding `cancel_tx` is dropped when the engine no longer needs this /// request (e.g. due to a target update), causing `cancel_rx.await` to return From 0d219c1079bcb1de8e4de8481e525fed9518cb4c Mon Sep 17 00:00:00 2001 From: Roberto Bayardo Date: Mon, 20 Apr 2026 15:14:20 -0700 Subject: [PATCH 5/8] bug fix for abstraction violation --- storage/src/qmdb/current/grafting.rs | 3 ++- storage/src/qmdb/current/sync/mod.rs | 28 +++++++++++----------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/storage/src/qmdb/current/grafting.rs b/storage/src/qmdb/current/grafting.rs index d4c6f8395b8..ce5dcb5db90 100644 --- a/storage/src/qmdb/current/grafting.rs +++ b/storage/src/qmdb/current/grafting.rs @@ -227,7 +227,8 @@ pub(super) fn ops_to_grafted_pos( F::subtree_root_position(grafted_leaf_loc, grafted_height) } -/// Convert a grafted position back to the corresponding ops-family position. +/// Convert a grafted position to the ops-family position whose subtree covers the same ops-leaf +/// range. pub(super) fn grafted_to_ops_pos( grafted_pos: Position, grafting_height: u32, diff --git a/storage/src/qmdb/current/sync/mod.rs b/storage/src/qmdb/current/sync/mod.rs index 9446fb3367f..d2d87f7c036 100644 --- a/storage/src/qmdb/current/sync/mod.rs +++ b/storage/src/qmdb/current/sync/mod.rs @@ -163,29 +163,23 @@ where ) .await?; - // Extract grafted pinned nodes from the ops tree. - // - // With the zero-chunk identity, all-zero bitmap chunks (which all pruned chunks are) - // produce grafted leaves equal to the corresponding ops subtree root. The grafted - // tree's pinned nodes for the pruned region are therefore the first - // `popcount(pruned_chunks)` ops pinned nodes (in decreasing height order). - // - // `nodes_to_pin(range.start)` returns all ops peaks, but only the first - // `popcount(pruned_chunks)` are at or above the grafting height. The remaining - // smaller peaks cover the partial trailing chunk and are not grafted pinned nodes. + // Fetch grafted pinned nodes from the ops tree. For each position the grafted family + // needs at its pruning boundary, source the digest from the ops tree via the zero-chunk + // identity: when the covered chunks are all zero (which pruned chunks always are), the + // ops-family digest at the mapped position equals the grafted digest. // // Requires `range.start <=` target's [`Db::sync_boundary`](db::Db::sync_boundary): that - // bound guarantees every fully-pruned chunk's height-`gh` subtree is absorbed at - // `range.end`, so `nodes_to_pin` returns the correct positions for any merkle family. + // bound guarantees every required ops-tree node is born at `range.end`. let grafted_pinned_nodes = { - let ops_pin_positions: Vec<_> = F::nodes_to_pin(range.start()).collect(); - let num_grafted_pins = (pruned_chunks as u64).count_ones() as usize; - let mut pins = Vec::with_capacity(num_grafted_pins); - for pos in ops_pin_positions.into_iter().take(num_grafted_pins) { + let grafted_boundary = Location::::new(pruned_chunks as u64); + let grafting_height = grafting::height::(); + let mut pins = Vec::new(); + for grafted_pos in F::nodes_to_pin(grafted_boundary) { + let ops_pos = grafting::grafted_to_ops_pos::(grafted_pos, grafting_height); let digest = any .log .merkle - .get_node(pos) + .get_node(ops_pos) .await? .ok_or(qmdb::Error::::DataCorrupted("missing ops pinned node"))?; pins.push(digest); From 4cb600baf3e5ab576a79ae6fcf5e6efa55f9c14b Mon Sep 17 00:00:00 2001 From: Roberto Bayardo Date: Mon, 20 Apr 2026 15:36:41 -0700 Subject: [PATCH 6/8] derive inactivity floor from last commit, not range.start --- storage/src/qmdb/any/sync/mod.rs | 19 ++++----- storage/src/qmdb/any/unordered/mod.rs | 55 ++------------------------- 2 files changed, 13 insertions(+), 61 deletions(-) diff --git a/storage/src/qmdb/any/sync/mod.rs b/storage/src/qmdb/any/sync/mod.rs index 8440e7fde73..ad25c1266d0 100644 --- a/storage/src/qmdb/any/sync/mod.rs +++ b/storage/src/qmdb/any/sync/mod.rs @@ -13,6 +13,7 @@ use crate::{ self, any::{ db::Db, + operation::{update::Update, Operation}, ordered::{ fixed::{ Db as OrderedFixedDb, Operation as OrderedFixedOp, Update as OrderedFixedUpdate, @@ -34,12 +35,12 @@ use crate::{ }, FixedConfig, FixedValue, VariableConfig, VariableValue, }, - operation::{Committable, Key, Operation}, + operation::{Committable, Key}, }, translator::Translator, - Context, + Context, Persistable, }; -use commonware_codec::{CodecShared, Read as CodecRead}; +use commonware_codec::{Codec, CodecShared, Read as CodecRead}; use commonware_cryptography::Hasher; use commonware_utils::{range::NonEmptyRange, Array}; @@ -81,7 +82,7 @@ where } /// Shared helper to build a [Db] from sync components. -async fn build_db( +async fn build_db( context: E, merkle_config: journaled::Config, log: C, @@ -93,12 +94,12 @@ async fn build_db( where F: merkle::Family, E: Context, - O: Operation + Committable + CodecShared + Send + Sync + 'static, + U: Update + Send + Sync + 'static, I: IndexFactory>, H: Hasher, - U: Send + Sync + 'static, T: Translator, - C: Mutable, + C: Mutable> + Persistable, + Operation: Codec + Committable + CodecShared, { let hasher = StandardHasher::::new(); @@ -122,7 +123,7 @@ where apply_batch_size as u64, ) .await?; - let db = Db::from_components(range.start(), log, index).await?; + let db = Db::init_from_log(index, log, Some(range.start()), |_, _| {}).await?; Ok(db) } @@ -160,7 +161,7 @@ macro_rules! impl_sync_database { ) -> Result> { let merkle_config = config.merkle_config.clone(); let translator = config.translator.clone(); - build_db::, _, T>( + build_db::, _, H, _, T>( context, merkle_config, log, diff --git a/storage/src/qmdb/any/unordered/mod.rs b/storage/src/qmdb/any/unordered/mod.rs index 6eb6ac39bf2..08e1579ab57 100644 --- a/storage/src/qmdb/any/unordered/mod.rs +++ b/storage/src/qmdb/any/unordered/mod.rs @@ -2,15 +2,11 @@ use crate::qmdb::any::traits::PersistableMutableLog; use crate::{ index::Unordered as Index, - journal::contiguous::{Contiguous, Mutable, Reader}, + journal::contiguous::{Contiguous, Reader}, merkle::{Family, Location}, qmdb::{ - any::{ - db::{AuthenticatedLog, Db}, - ValueEncoding, - }, - build_snapshot_from_log, - operation::{Committable, Key, Operation as OperationTrait}, + any::{db::Db, ValueEncoding}, + operation::Key, }, Context, }; @@ -59,51 +55,6 @@ where } } -impl< - F: Family, - E: Context, - C: Mutable, - O: OperationTrait + Codec + Committable + Send + Sync, - I: Index>, - H: Hasher, - U: Send + Sync, - > Db -{ - /// Returns an [Db] initialized directly from the given components. The log is - /// replayed from `inactivity_floor_loc` to build the snapshot, and that value is used as the - /// inactivity floor. The last operation is assumed to be a commit. - pub(crate) async fn from_components( - inactivity_floor_loc: Location, - log: AuthenticatedLog, - mut snapshot: I, - ) -> Result> { - let (active_keys, last_commit_loc) = { - let reader = log.reader().await; - let active_keys = - build_snapshot_from_log(inactivity_floor_loc, &reader, &mut snapshot, |_, _| {}) - .await?; - let last_commit_loc = Location::new( - reader - .bounds() - .end - .checked_sub(1) - .expect("commit should exist"), - ); - assert!(reader.read(*last_commit_loc).await?.is_commit()); - (active_keys, last_commit_loc) - }; - - Ok(Self { - log, - inactivity_floor_loc, - snapshot, - last_commit_loc, - active_keys, - _update: core::marker::PhantomData, - }) - } -} - #[cfg(any(test, feature = "test-traits"))] crate::qmdb::any::traits::impl_db_any! { [E, K, V, C, I, H] Db> From 7ffaec9ff193a738eccd3dc188f578a15692907a Mon Sep 17 00:00:00 2001 From: Roberto Bayardo Date: Mon, 20 Apr 2026 15:45:54 -0700 Subject: [PATCH 7/8] fix rebase --- storage/src/qmdb/current/db.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/storage/src/qmdb/current/db.rs b/storage/src/qmdb/current/db.rs index 2aed1359e73..60c68812158 100644 --- a/storage/src/qmdb/current/db.rs +++ b/storage/src/qmdb/current/db.rs @@ -1261,7 +1261,7 @@ mod tests { for _ in 0..5 { populate_fixed_db::(&mut db, 0, 512).await; } - db.prune(db.inactivity_floor_loc()).await.unwrap(); + db.prune(db.sync_boundary()).await.unwrap(); assert!( db.status.pruned_chunks() > 0, "test requires at least one pruned chunk to exercise the zero-chunk path" From de3324e8af006cbe03e2be5362526f0f8c62fb43 Mon Sep 17 00:00:00 2001 From: Roberto Bayardo Date: Tue, 21 Apr 2026 10:07:16 -0700 Subject: [PATCH 8/8] address review --- storage/src/qmdb/current/sync/mod.rs | 2 +- storage/src/qmdb/immutable/sync/tests.rs | 23 +++++++++++----------- storage/src/qmdb/keyless/sync/tests.rs | 25 ++++++++++++------------ 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/storage/src/qmdb/current/sync/mod.rs b/storage/src/qmdb/current/sync/mod.rs index d2d87f7c036..c368772f106 100644 --- a/storage/src/qmdb/current/sync/mod.rs +++ b/storage/src/qmdb/current/sync/mod.rs @@ -117,7 +117,7 @@ where // Build authenticated log. let hasher = StandardHasher::::new(); let merkle = Journaled::::init_sync( - context.with_label("mmr"), + context.with_label("merkle"), journaled::SyncConfig { config: merkle_config, range: range.clone(), diff --git a/storage/src/qmdb/immutable/sync/tests.rs b/storage/src/qmdb/immutable/sync/tests.rs index 77a806e8b9b..fb4f2fa4b06 100644 --- a/storage/src/qmdb/immutable/sync/tests.rs +++ b/storage/src/qmdb/immutable/sync/tests.rs @@ -38,7 +38,6 @@ use std::{ pub(crate) type DbOf = ::Db; pub(crate) type OpOf = as qmdb::sync::Database>::Op; pub(crate) type ConfigOf = as qmdb::sync::Database>::Config; -pub(crate) type FamilyOf = as qmdb::sync::Database>::Family; pub(crate) type JournalOf = as qmdb::sync::Database>::Journal; const PAGE_SIZE: NonZeroU16 = NZU16!(77); @@ -93,7 +92,7 @@ pub(crate) trait SyncTestHarness: Sized + 'static { pub(crate) fn test_sync(target_db_ops: usize, fetch_batch_size: NonZeroU64) where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -164,7 +163,7 @@ where pub(crate) fn test_sync_empty_to_nonempty() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -212,7 +211,7 @@ where pub(crate) fn test_sync_database_persistence() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|context| async move { @@ -279,7 +278,7 @@ where pub(crate) fn test_target_update_during_sync() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, JournalOf: Contiguous, { let executor = deterministic::Runner::default(); @@ -368,7 +367,7 @@ where pub(crate) fn test_sync_subset_of_target_database() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -415,7 +414,7 @@ where pub(crate) fn test_sync_use_existing_db_partial_match() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -469,7 +468,7 @@ where pub(crate) fn test_sync_use_existing_db_exact_match() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -521,7 +520,7 @@ where pub(crate) fn test_target_update_lower_bound_decrease() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -584,7 +583,7 @@ where pub(crate) fn test_target_update_upper_bound_decrease() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -642,7 +641,7 @@ where pub(crate) fn test_target_update_bounds_increase() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -713,7 +712,7 @@ where pub(crate) fn test_target_update_on_done_client() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { diff --git a/storage/src/qmdb/keyless/sync/tests.rs b/storage/src/qmdb/keyless/sync/tests.rs index 69df2e9c33a..86d53e1c791 100644 --- a/storage/src/qmdb/keyless/sync/tests.rs +++ b/storage/src/qmdb/keyless/sync/tests.rs @@ -35,7 +35,6 @@ use std::{ pub(crate) type DbOf = ::Db; pub(crate) type OpOf = as qmdb::sync::Database>::Op; pub(crate) type ConfigOf = as qmdb::sync::Database>::Config; -pub(crate) type FamilyOf = as qmdb::sync::Database>::Family; pub(crate) type JournalOf = as qmdb::sync::Database>::Journal; const PAGE_SIZE: NonZeroU16 = NZU16!(77); @@ -94,7 +93,7 @@ where { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { - let resolver = FailResolver::, OpOf, sha256::Digest>::new(); + let resolver = FailResolver::, sha256::Digest>::new(); let db_config = H::config(&context.next_u64().to_string(), &context); let config = Config { context: context.with_label("client"), @@ -121,7 +120,7 @@ where pub(crate) fn test_sync(target_db_ops: usize, fetch_batch_size: NonZeroU64) where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -184,7 +183,7 @@ where pub(crate) fn test_sync_empty_to_nonempty() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -232,7 +231,7 @@ where pub(crate) fn test_sync_database_persistence() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|context| async move { @@ -299,7 +298,7 @@ where pub(crate) fn test_target_update_during_sync() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, JournalOf: Contiguous, { let executor = deterministic::Runner::default(); @@ -380,7 +379,7 @@ where pub(crate) fn test_sync_subset_of_target_database() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -427,7 +426,7 @@ where pub(crate) fn test_sync_use_existing_db_partial_match() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -481,7 +480,7 @@ where pub(crate) fn test_sync_use_existing_db_exact_match() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -533,7 +532,7 @@ where pub(crate) fn test_target_update_lower_bound_decrease() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -596,7 +595,7 @@ where pub(crate) fn test_target_update_upper_bound_decrease() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -654,7 +653,7 @@ where pub(crate) fn test_target_update_bounds_increase() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move { @@ -725,7 +724,7 @@ where pub(crate) fn test_target_update_on_done_client() where OpOf: Encode + Clone + Send + Sync, - Arc>: Resolver, Op = OpOf, Digest = sha256::Digest>, + Arc>: Resolver, Digest = sha256::Digest>, { let executor = deterministic::Runner::default(); executor.start(|mut context| async move {