//! Compact [`ManagedDb`] implementation for QMDB //! [`immutable`](commonware_storage::qmdb::immutable) databases. //! //! These compact databases retain only the current Merkle peaks, so the glue //! adapters expose set and merkleization operations but no historical reads. use crate::stateful::db::{ ManagedDb, Merkleized as MerkleizedTrait, StateSyncDb, SyncEngineConfig, Unmerkleized as UnmerkleizedTrait, }; use commonware_codec::{EncodeShared, Read as CodecRead}; use commonware_cryptography::Hasher; use commonware_parallel::Strategy; use commonware_storage::{ merkle::{Family, Location}, qmdb::{ any::value::{FixedEncoding, FixedValue, ValueEncoding, VariableEncoding, VariableValue}, immutable::{ fixed, initial_root, variable, CompactDb, CompactMerkleizedBatch, CompactUnmerkleizedBatch, Operation, }, operation::Key, sync::{self}, Error, }, Context, }; use commonware_utils::{channel::mpsc, sync::TracedAsyncRwLock, Array}; use std::{ops::Deref, sync::Arc}; type ImmutableUnjournaledDbHandle = Arc>>; /// Wraps an unjournaled immutable batch before merkleization. pub struct ImmutableUnjournaledUnmerkleized where F: Family, E: Context, K: Key, V: ValueEncoding, H: Hasher, Operation: EncodeShared, Operation: CodecRead, C: Clone + Send + Sync + 'static, S: Strategy, { batch: CompactUnmerkleizedBatch, db: ImmutableUnjournaledDbHandle, metadata: Option, inactivity_floor: Option>, } impl Deref for ImmutableUnjournaledUnmerkleized where F: Family, E: Context, K: Key, V: ValueEncoding, H: Hasher, Operation: EncodeShared, Operation: CodecRead, C: Clone + Send + Sync + 'static, S: Strategy, { type Target = CompactUnmerkleizedBatch; fn deref(&self) -> &Self::Target { &self.batch } } impl ImmutableUnjournaledUnmerkleized where F: Family, E: Context, K: Key, V: ValueEncoding, H: Hasher, Operation: EncodeShared, Operation: CodecRead, C: Clone + Send + Sync + 'static, S: Strategy, { /// Set commit metadata included in the next merkleization. pub fn with_metadata(mut self, metadata: V::Value) -> Self { self.metadata = Some(metadata); self } /// Set the inactivity floor included in the next merkleization. pub const fn with_inactivity_floor(mut self, floor: Location) -> Self { self.inactivity_floor = Some(floor); self } /// Set `key` to `value` in the speculative batch. pub fn set(mut self, key: K, value: V::Value) -> Self { self.batch = self.batch.set(key, value); self } } /// Wraps an unjournaled immutable batch after merkleization. pub struct ImmutableUnjournaledMerkleized where F: Family, E: Context, K: Key, V: ValueEncoding, H: Hasher, Operation: EncodeShared, Operation: CodecRead, C: Clone + Send + Sync + 'static, S: Strategy, { inner: Arc>, db: ImmutableUnjournaledDbHandle, } impl Deref for ImmutableUnjournaledMerkleized where F: Family, E: Context, K: Key, V: ValueEncoding, H: Hasher, Operation: EncodeShared, Operation: CodecRead, C: Clone + Send + Sync + 'static, S: Strategy, { type Target = CompactMerkleizedBatch; fn deref(&self) -> &Self::Target { &self.inner } } impl UnmerkleizedTrait for ImmutableUnjournaledUnmerkleized where F: Family, E: Context, K: Key, V: ValueEncoding, H: Hasher, Operation: EncodeShared, Operation: CodecRead, C: Clone + Send + Sync + 'static, S: Strategy, { type Merkleized = ImmutableUnjournaledMerkleized; type Error = Error; async fn merkleize(self) -> Result> { let db = self.db.read().await; let merkleized = self .batch .merkleize( &*db, self.metadata, self.inactivity_floor.unwrap_or_default(), ) .await; Ok(ImmutableUnjournaledMerkleized { inner: merkleized, db: self.db.clone(), }) } } impl MerkleizedTrait for ImmutableUnjournaledMerkleized where F: Family, E: Context, K: Key, V: ValueEncoding, H: Hasher, Operation: EncodeShared, Operation: CodecRead, C: Clone + Send + Sync + 'static, S: Strategy, { type Digest = H::Digest; type Unmerkleized = ImmutableUnjournaledUnmerkleized; fn root(&self) -> H::Digest { self.inner.root() } fn new_batch(&self) -> Self::Unmerkleized { ImmutableUnjournaledUnmerkleized { batch: self.inner.new_batch::(), db: self.db.clone(), metadata: None, inactivity_floor: None, } } } impl ManagedDb for fixed::CompactDb where F: Family, E: Context, K: Array, V: FixedValue + 'static, H: Hasher + 'static, S: Strategy, Operation>: EncodeShared + CodecRead, { type Unmerkleized = ImmutableUnjournaledUnmerkleized, H, S, ()>; type Merkleized = ImmutableUnjournaledMerkleized, H, S, ()>; type Error = Error; type Config = fixed::CompactConfig; type SyncTarget = sync::compact::Target; async fn init(context: E, config: Self::Config) -> Result> { ::init(context, config).await } fn initial_sync_target() -> Self::SyncTarget { sync::compact::Target::new( initial_root::, H>(), Location::new(1), ) } async fn new_batch(db: &Arc>) -> Self::Unmerkleized { let inner = db.read().await; ImmutableUnjournaledUnmerkleized { batch: inner.new_batch(), db: db.clone(), metadata: None, inactivity_floor: None, } } fn matches_sync_target(batch: &Self::Merkleized, target: &Self::SyncTarget) -> bool { batch.root() == target.root && target.leaf_count == Location::new(batch.bounds().total_size) } async fn finalize(&mut self, batch: Self::Merkleized) -> Result<(), Error> { self.apply_batch(batch.inner)?; self.sync().await } async fn prune(&mut self, target: &Self::SyncTarget) -> Result<(), Error> { Self::prune(self, target.leaf_count).await } fn sync_target(&self) -> Self::SyncTarget { self.target() } async fn rewind_to_target(&mut self, target: Self::SyncTarget) -> Result<(), Error> { self.rewind(target.leaf_count).await?; let rewound_target = self.sync_target(); assert_eq!( rewound_target, target, "rewound database target mismatch after rewind", ); Ok(()) } } impl ManagedDb for variable::CompactDb where F: Family, E: Context, K: Key, V: VariableValue + 'static, H: Hasher + 'static, Operation>: EncodeShared + CodecRead, C: Clone + Send + Sync + 'static, S: Strategy, { type Unmerkleized = ImmutableUnjournaledUnmerkleized, H, S, C>; type Merkleized = ImmutableUnjournaledMerkleized, H, S, C>; type Error = Error; type Config = variable::CompactConfig; type SyncTarget = sync::compact::Target; async fn init(context: E, config: Self::Config) -> Result> { ::init(context, config).await } fn initial_sync_target() -> Self::SyncTarget { sync::compact::Target::new( initial_root::, H>(), Location::new(1), ) } async fn new_batch(db: &Arc>) -> Self::Unmerkleized { let inner = db.read().await; ImmutableUnjournaledUnmerkleized { batch: inner.new_batch(), db: db.clone(), metadata: None, inactivity_floor: None, } } fn matches_sync_target(batch: &Self::Merkleized, target: &Self::SyncTarget) -> bool { batch.root() == target.root && target.leaf_count == Location::new(batch.bounds().total_size) } async fn finalize(&mut self, batch: Self::Merkleized) -> Result<(), Error> { self.apply_batch(batch.inner)?; self.sync().await } async fn prune(&mut self, target: &Self::SyncTarget) -> Result<(), Error> { Self::prune(self, target.leaf_count).await } fn sync_target(&self) -> Self::SyncTarget { self.target() } async fn rewind_to_target(&mut self, target: Self::SyncTarget) -> Result<(), Error> { self.rewind(target.leaf_count).await?; let rewound_target = self.sync_target(); assert_eq!( rewound_target, target, "rewound database target mismatch after rewind", ); Ok(()) } } impl StateSyncDb for fixed::CompactDb where F: Family, E: Context, K: Array, V: FixedValue + 'static, H: Hasher + 'static, S: Strategy, Operation>: EncodeShared + CodecRead, R: sync::compact::Resolver< Family = F, Op = Operation>, Digest = H::Digest, >, { type SyncError = sync::Error; async fn sync_db( context: E, config: Self::Config, resolver: R, target: Self::SyncTarget, tip_updates: mpsc::Receiver, finish: Option>, reached_target: Option>, _sync_config: SyncEngineConfig, ) -> Result { sync::compact::sync(sync::compact::Config { context, resolver, target, db_config: config, update_rx: Some(tip_updates), finish_rx: finish, reached_target_tx: reached_target, }) .await } } impl StateSyncDb for variable::CompactDb where F: Family, E: Context, K: Key, V: VariableValue + 'static, H: Hasher + 'static, Operation>: EncodeShared + CodecRead, C: Clone + Send + Sync + 'static, S: Strategy, R: sync::compact::Resolver< Family = F, Op = Operation>, Digest = H::Digest, >, { type SyncError = sync::Error; async fn sync_db( context: E, config: Self::Config, resolver: R, target: Self::SyncTarget, tip_updates: mpsc::Receiver, finish: Option>, reached_target: Option>, _sync_config: SyncEngineConfig, ) -> Result { sync::compact::sync(sync::compact::Config { context, resolver, target, db_config: config, update_rx: Some(tip_updates), finish_rx: finish, reached_target_tx: reached_target, }) .await } } #[cfg(test)] mod tests { use super::*; use commonware_cryptography::{sha256::Digest, Sha256}; use commonware_macros::select; use commonware_parallel::Sequential; use commonware_runtime::{ buffer::paged::CacheRef, deterministic, BufferPooler, Clock as _, Metrics as _, Runner as _, Spawner as _, Supervisor as _, }; use commonware_storage::{ journal::contiguous::fixed::Config as FixedJournalConfig, merkle::{full::Config as MerkleConfig, mmr}, translator::TwoCap, }; use commonware_utils::{NZUsize, NZU16, NZU64}; use futures::pin_mut; use std::time::Duration; type FixedDb = fixed::CompactDb; type FullFixedDb = fixed::Db; type VariableDb = variable::CompactDb< mmr::Family, deterministic::Context, Digest, Vec, Sha256, ((), (commonware_codec::RangeCfg, ())), Sequential, >; fn fixed_config(context: &impl BufferPooler, suffix: &str) -> fixed::CompactConfig { fixed::CompactConfig { strategy: Sequential, witness: commonware_storage::journal::contiguous::variable::Config { partition: format!("stateful-immutable-unjournaled-{suffix}-witness"), items_per_section: NZU64!(64), compression: None, codec_config: (), page_cache: CacheRef::from_pooler(context, NZU16!(101), NZUsize!(11)), write_buffer: NZUsize!(1024), }, commit_codec_config: (), } } fn full_fixed_config( context: &impl BufferPooler, suffix: &str, ) -> fixed::Config { let page_cache = CacheRef::from_pooler(context, NZU16!(101), NZUsize!(11)); fixed::Config { merkle_config: MerkleConfig { journal_partition: format!("stateful-immutable-full-journal-{suffix}"), metadata_partition: format!("stateful-immutable-full-metadata-{suffix}"), items_per_blob: NZU64!(11), write_buffer: NZUsize!(1024), strategy: Sequential, page_cache: page_cache.clone(), }, log: FixedJournalConfig { partition: format!("stateful-immutable-full-log-{suffix}"), items_per_blob: NZU64!(7), page_cache, write_buffer: NZUsize!(1024), }, translator: TwoCap, init_cache_size: Some(NZUsize!(1024)), } } const fn sync_config() -> SyncEngineConfig { SyncEngineConfig { fetch_batch_size: NZU64!(1), apply_batch_size: 1, max_outstanding_requests: 1, update_channel_size: NZUsize!(1), max_retained_roots: 0, } } fn assert_managed_db>() {} fn assert_state_sync_db() where T: StateSyncDb, { } #[derive(Clone)] struct SupersedingCompactResolver { source: Arc, stale_target: sync::compact::Target, stale_request_tx: mpsc::Sender<()>, } impl sync::compact::Resolver for SupersedingCompactResolver { type Family = mmr::Family; type Digest = Digest; type Op = fixed::Operation; type Error = sync::compact::ServeError; async fn get_compact_state( &self, target: sync::compact::Target, ) -> Result, Self::Error> { if target == self.stale_target { let _ = self.stale_request_tx.send(()).await; return futures::future::pending().await; } sync::compact::Resolver::get_compact_state(&self.source, target).await } } #[test] fn immutable_unjournaled_trait_impls_compile() { assert_managed_db::(); assert_managed_db::(); assert_state_sync_db::>(); assert_state_sync_db::>(); } #[test] fn managed_db_finalize_commits_fixed_immutable_unjournaled_batches() { deterministic::Runner::default().start(|context| async move { let config = fixed_config(&context, "managed-db"); let db = FixedDb::init(context.child("db"), config).await.unwrap(); let db = Arc::new(TracedAsyncRwLock::new("test", db)); let key = Sha256::hash(&[1]); let value = Sha256::hash(&[2]); let metadata = Sha256::hash(&[3]); let batch = >::new_batch(&db) .await .set(key, value) .with_inactivity_floor(mmr::Location::new(1)) .with_metadata(metadata); let merkleized = crate::stateful::db::Unmerkleized::merkleize(batch) .await .unwrap(); let expected_root = merkleized.root(); { let mut guard = db.write().await; >::finalize(&mut *guard, merkleized) .await .unwrap(); } let guard = db.read().await; assert_eq!(guard.root(), expected_root); assert_eq!(guard.get_metadata(), Some(metadata)); let target = >::sync_target(&*guard); assert_eq!(target.root, guard.root()); assert_eq!(target.leaf_count, mmr::Location::new(3)); }); } #[test] fn state_sync_fetches_fixed_immutable_compact_state() { deterministic::Runner::default().start(|context| async move { let mut source = FixedDb::init(context.child("source"), fixed_config(&context, "source")) .await .unwrap(); let metadata = Sha256::hash(&[3]); let floor = source.inactivity_floor_loc(); let batch = source .new_batch() .set(Sha256::hash(&[1]), Sha256::hash(&[2])) .merkleize(&source, Some(metadata), floor) .await; source.apply_batch(batch).unwrap(); source.sync().await.unwrap(); let target = source.target(); let (_update_tx, update_rx) = mpsc::channel(1); let synced = >>::sync_db( context.child("target"), fixed_config(&context, "target"), Arc::new(source), target.clone(), update_rx, None, None, sync_config(), ) .await .unwrap(); assert_eq!(synced.target(), target); assert_eq!(synced.get_metadata(), Some(metadata)); }); } #[test] fn state_sync_reports_compact_progress() { deterministic::Runner::default().start(|context| async move { let source_context = context.child("source"); let source_config = full_fixed_config(&source_context, "source"); let mut source = FullFixedDb::init(source_context, source_config) .await .unwrap(); let floor = source.inactivity_floor_loc(); let batch = source .new_batch() .set(Sha256::hash(&[1]), Sha256::hash(&[2])) .merkleize(&source, Some(Sha256::hash(&[3])), floor) .await; source.apply_batch(batch).await.unwrap(); source.sync().await.unwrap(); let target = sync::compact::Target { root: source.root(), leaf_count: source.bounds().end, }; // A larger target the resolver never serves. Its sync attempt // hangs so the test can observe the gauges while they diverge. let unservable_target = sync::compact::Target { root: Sha256::hash(&[0xFF]), leaf_count: Location::new(*target.leaf_count + 1), }; let (stale_request_tx, mut stale_request_rx) = mpsc::channel(1); let resolver = SupersedingCompactResolver { source: Arc::new(source), stale_target: unservable_target.clone(), stale_request_tx, }; let (update_tx, update_rx) = mpsc::channel(1); let (_finish_tx, finish_rx) = mpsc::channel(1); let (reached_tx, mut reached_rx) = mpsc::channel(1); let client_context = context.child("client"); let client_config = fixed_config(&client_context, "client"); let sync = >::sync_db( client_context, client_config, resolver, target.clone(), update_rx, Some(finish_rx), Some(reached_tx), sync_config(), ); pin_mut!(sync); select! { _ = sync.as_mut() => panic!("sync completed before explicit finish signal"), reached = reached_rx.recv() => assert_eq!(reached, Some(target.clone())), } let synced_leaves = *target.leaf_count; let encoded = context.encode(); assert!( encoded.contains(&format!("\nclient_target_leaf_count {synced_leaves}")), "missing compact sync target gauge: {encoded}" ); assert!( encoded.contains(&format!("\nclient_leaf_count {synced_leaves}")), "missing compact sync progress gauge: {encoded}" ); // Supersede with the unservable target and wait for its fetch to // start. The target gauge advances while the synced gauge still // reports the previously reached target. update_tx.send(unservable_target.clone()).await.unwrap(); select! { _ = sync.as_mut() => panic!("sync completed with an unservable target"), request = stale_request_rx.recv() => assert_eq!(request, Some(())), } let target_leaves = *unservable_target.leaf_count; let encoded = context.encode(); assert!( encoded.contains(&format!("\nclient_target_leaf_count {target_leaves}")), "target gauge should advance to the superseding target: {encoded}" ); assert!( encoded.contains(&format!("\nclient_leaf_count {synced_leaves}")), "synced gauge should still report the reached target: {encoded}" ); }); } #[test] fn state_sync_supersedes_in_flight_stale_compact_target() { deterministic::Runner::default().start(|context| async move { let mut source = FullFixedDb::init( context.child("source"), full_fixed_config(&context, "source"), ) .await .unwrap(); let floor = source.inactivity_floor_loc(); let batch = source .new_batch() .set(Sha256::hash(&[1]), Sha256::hash(&[2])) .merkleize(&source, Some(Sha256::hash(&[9])), floor) .await; source.apply_batch(batch).await.unwrap(); source.sync().await.unwrap(); let stale_target = sync::compact::Target { root: source.root(), leaf_count: source.bounds().end, }; let floor = source.inactivity_floor_loc(); let batch = source .new_batch() .set(Sha256::hash(&[3]), Sha256::hash(&[4])) .merkleize(&source, Some(Sha256::hash(&[10])), floor) .await; source.apply_batch(batch).await.unwrap(); source.sync().await.unwrap(); let latest_target = sync::compact::Target { root: source.root(), leaf_count: source.bounds().end, }; let (stale_request_tx, mut stale_request_rx) = mpsc::channel(1); let resolver = SupersedingCompactResolver { source: Arc::new(source), stale_target: stale_target.clone(), stale_request_tx, }; let (update_tx, update_rx) = mpsc::channel(1); let sync_handle = context.child("sync").spawn(move |context| async move { >::sync_db( context.child("target"), fixed_config(&context, "supersede-target"), resolver, stale_target, update_rx, None, None, sync_config(), ) .await }); context .timeout(Duration::from_secs(1), async move { stale_request_rx.recv().await.unwrap(); }) .await .expect("sync should request the stale target first"); update_tx.send(latest_target.clone()).await.unwrap(); let synced = context .timeout(Duration::from_secs(1), sync_handle) .await .expect("sync should switch to the latest target") .expect("spawned sync task should complete") .unwrap(); assert_eq!(synced.target(), latest_target); assert_eq!(synced.get_metadata(), Some(Sha256::hash(&[10]))); }); } #[test] fn managed_db_rewinds_fixed_immutable_unjournaled_multiple_commit_ranges() { deterministic::Runner::default().start(|context| async move { let config = fixed_config(&context, "rewind"); let mut db = FixedDb::init(context.child("db"), config).await.unwrap(); let floor = db.inactivity_floor_loc(); let batch = db .new_batch() .set(Sha256::hash(&[1]), Sha256::hash(&[2])) .merkleize(&db, Some(Sha256::hash(&[11])), floor) .await; db.apply_batch(batch).unwrap(); db.sync().await.unwrap(); let first_target = >::sync_target(&db); // Commit two more ranges so the rewind below spans multiple commits. for i in [3u8, 5] { let floor = db.inactivity_floor_loc(); let batch = db .new_batch() .set(Sha256::hash(&[i]), Sha256::hash(&[i + 1])) .merkleize(&db, Some(Sha256::hash(&[i * 11])), floor) .await; db.apply_batch(batch).unwrap(); db.sync().await.unwrap(); } let third_target = >::sync_target(&db); assert_ne!(third_target, first_target); >::rewind_to_target(&mut db, first_target.clone()) .await .unwrap(); let rewound_target = >::sync_target(&db); assert_eq!(rewound_target, first_target); assert_eq!(db.get_metadata(), Some(Sha256::hash(&[11]))); }); } #[test] fn managed_db_prune_bounds_fixed_immutable_unjournaled_rewind_history() { deterministic::Runner::default().start(|context| async move { // One witness entry per section so pruning takes effect at entry granularity. let mut config = fixed_config(&context, "prune"); config.witness.items_per_section = NZU64!(1); let mut db = FixedDb::init(context.child("db"), config).await.unwrap(); // Commit three ranges, recording each target. let mut targets = Vec::new(); for i in [1u8, 3, 5] { let floor = db.inactivity_floor_loc(); let batch = db .new_batch() .set(Sha256::hash(&[i]), Sha256::hash(&[i + 1])) .merkleize(&db, Some(Sha256::hash(&[i * 11])), floor) .await; db.apply_batch(batch).unwrap(); db.sync().await.unwrap(); targets.push(>::sync_target(&db)); } assert_ne!(targets[0], targets[1]); // Prune to the second target: the first is no longer a rewind target, but the // second still is. >::prune(&mut db, &targets[1]) .await .unwrap(); assert!(matches!( db.rewind(targets[0].leaf_count).await, Err(Error::Merkle( commonware_storage::merkle::Error::RewindBeyondHistory )) )); >::rewind_to_target(&mut db, targets[1].clone()) .await .unwrap(); assert_eq!(>::sync_target(&db), targets[1]); }); } }