#![allow(clippy::type_complexity)]
use std::hash::Hash;
use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use std::{collections::BTreeMap, path::PathBuf};
use crate::sync::{Mutex, lock};
use armour_core::persist::Persist;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::compaction::Compactor;
use crate::error::SchemaMismatchKind;
use crate::flusher::Flusher;
use crate::format_migration::{
StorageDescriptor,
bitcask::BackendMigrationOutcome,
fixed::migrate_fixed,
marker::{
FormatMarker, cleanup_stale_format_temps, read_collection_marker, remove_collection_marker,
write_collection_marker,
},
plan::{MigrationPlan, build_format_only_plan, build_plan, build_replay_plan},
};
use crate::hook::TypedWriteHook;
#[cfg(all(feature = "typed-tree", feature = "armour"))]
use crate::queue::wait::QueueWakeService;
use crate::shutdown::ShutdownSignal;
use crate::sweeper::Sweeper;
use crate::ttl::{SweepOutcome, log_sweep_failures};
use crate::{
Codec, CollectionMeta, Config, DbError, DbResult, FixedConfig, Key, NoHook, TreeMeta, TtlHook,
};
use crate::{
TypedMap, TypedTree, UniqueIndex, UniqueNamespace, UniqueRow, UniqueTargets, ZeroMap, ZeroTree,
};
use super::collection::Collection;
use super::inspection::InspectionHandler;
use super::migration::TypedMigration;
use super::seq::SeqGen;
#[cfg(all(feature = "typed-tree", feature = "armour"))]
use crate::queue::{Queue, QueueConfig, QueueSpec, retention::QueueRetentionPass};
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub struct DbInfo {
#[serde(default)]
pub version: u32,
pub collections: BTreeMap<String, CollectionInfo>,
#[serde(default)]
pub clean: bool,
}
#[cfg(feature = "fjall")]
pub(crate) struct ManagedLsmLifetime {
pub(crate) db_info: Persist<DbInfo>,
pub(crate) _root_lease: Arc<std::fs::File>,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub struct CollectionInfo {
pub version: u16,
pub typ_hash: u64,
#[serde(default)]
pub seq: u64,
#[serde(default)]
pub projections_dirty: bool,
#[serde(default)]
pub counters_dirty: bool,
#[serde(default)]
pub gsn_high: u64,
#[serde(default)]
pub shard_gsn_high: Vec<u64>,
#[serde(default)]
pub unique_dirty: bool,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
struct UserMeta {
db: Option<serde_json::Value>,
collections: BTreeMap<String, serde_json::Value>,
}
const DEFAULT_COMPACTION_INTERVAL: Duration = Duration::from_secs(60);
const DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(1);
const DEFAULT_TTL_SWEEP_INTERVAL: Duration = Duration::from_secs(60);
const DEFAULT_TTL_BUDGET: usize = 4096;
#[derive(Debug, Clone, Copy)]
pub struct WorkerIntervals {
pub compaction: Option<Duration>,
pub flush: Option<Duration>,
pub ttl_sweep: Option<Duration>,
pub ttl_budget: usize,
}
impl Default for WorkerIntervals {
fn default() -> Self {
Self {
compaction: Some(DEFAULT_COMPACTION_INTERVAL),
flush: Some(DEFAULT_FLUSH_INTERVAL),
ttl_sweep: Some(DEFAULT_TTL_SWEEP_INTERVAL),
ttl_budget: DEFAULT_TTL_BUDGET,
}
}
}
const RESERVED_COLLECTION_NAMES: [&str; 5] = [
"__seq",
"db.info",
"db.user",
crate::lock::LOCK_FILE_NAME,
"txlog",
];
pub const TYP_HASH_MIGRATING: u64 = 0;
#[cfg(test)]
mod format_migration_metadata_tests {
use super::CollectionInfo;
#[test]
fn collection_info_new_fields_default_when_absent_from_stored_json() {
let info: CollectionInfo = serde_json::from_str(
r#"{"version":1,"typ_hash":2,"seq":3,"projections_dirty":true,"counters_dirty":true}"#,
)
.unwrap();
assert_eq!(info.gsn_high, 0);
assert!(info.shard_gsn_high.is_empty());
assert!(!info.unique_dirty);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingTx {
pub tx_id: u64,
pub collections: Vec<String>,
}
pub struct Db {
pub path: PathBuf,
db_info: Persist<DbInfo>,
db_info_arc: Arc<Persist<DbInfo>>,
user_meta: Persist<UserMeta>,
pub seq: Arc<SeqGen>,
pub(crate) shutdown: ShutdownSignal,
#[cfg(all(feature = "typed-tree", feature = "armour"))]
queue_wake_service: Arc<QueueWakeService>,
pub(crate) collections: Arc<Mutex<Vec<Arc<dyn Collection>>>>,
#[cfg(all(feature = "typed-tree", feature = "armour"))]
queue_retentions: Arc<Mutex<Vec<Arc<dyn QueueRetentionPass>>>>,
pub(crate) inspection_handlers: Arc<Mutex<BTreeMap<&'static str, Arc<dyn InspectionHandler>>>>,
reserved_names: Mutex<std::collections::HashSet<&'static str>>,
#[cfg(feature = "rpc")]
pub(crate) handlers: Arc<Mutex<Vec<(u64, Arc<dyn super::handler::RpcHandler>)>>>,
#[cfg(feature = "rpc")]
pub(crate) rpc_handles: Mutex<Vec<super::rpc::RpcHandle>>,
compactor: Option<Compactor>,
flusher: Option<Flusher>,
sweeper: Option<Sweeper>,
ttl_budget: usize,
#[cfg(feature = "fjall")]
lsm_db: Mutex<Option<(fjall::Database, crate::lsm::LsmDbConfig)>>,
#[cfg(feature = "fjall")]
#[allow(dead_code)]
managed_lsm_lifetime: Arc<ManagedLsmLifetime>,
#[cfg(feature = "fjall")]
lsm_clean_marker: Arc<crate::lsm::ManagedLsmCleanMarker>,
#[cfg(feature = "fjall")]
opened_clean: bool,
tx_config: crate::txn::TxConfig,
txlog: Arc<crate::txlog::TxLog>,
tx_replay_done: Mutex<std::collections::HashSet<&'static str>>,
tx_ranks: Arc<Mutex<std::collections::HashMap<&'static str, u8>>>,
claim_owner_tags: Mutex<std::collections::HashMap<(&'static str, u64), &'static str>>,
counter_targets_consumed: Mutex<std::collections::HashSet<&'static str>>,
_lock: Arc<std::fs::File>,
pub(crate) tx_runtime: Arc<crate::txn::TxRuntime>,
}
#[derive(Clone, Copy)]
enum FinalizeMode {
Close,
CleanShutdown,
}
fn note_first_error(first: &mut Option<DbError>, error: DbError) {
if first.is_none() {
*first = Some(error);
}
}
struct NameReservation<'a> {
db: &'a Db,
name: &'static str,
}
struct StagedManaged<'a, S, W> {
meta: TreeMeta,
collection: Arc<S>,
wire: W,
_name_guard: NameReservation<'a>,
}
struct StagedAdapter<'a> {
name: &'static str,
collection: Arc<dyn Collection>,
inspection: Arc<dyn InspectionHandler>,
#[cfg(feature = "rpc")]
rpc: Option<(u64, Arc<dyn super::handler::RpcHandler>)>,
_name_guard: NameReservation<'a>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ManagedCollectionAccess {
Public,
Internal,
}
impl Drop for NameReservation<'_> {
fn drop(&mut self) {
lock(&self.db.reserved_names).remove(self.name);
}
}
fn ttl_sweep_collection(
c: &Arc<dyn Collection>,
now_ms: u64,
budget: usize,
) -> DbResult<SweepOutcome> {
let name = c.name();
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
c.sweep_expired(now_ms, budget)
})) {
Ok(Ok(out)) => {
log_sweep_failures(Some(name), &out);
Ok(out)
}
Ok(Err(e)) => Err(e),
Err(_) => {
tracing::error!(
collection = name,
"ttl sweep panicked in deadline extractor; skipping collection for this pass"
);
Ok(SweepOutcome::default())
}
}
}
impl Db {
pub(crate) fn queue_wake_service(&self) -> Arc<QueueWakeService> {
Arc::clone(&self.queue_wake_service)
}
pub fn open_queue<N>(&self, config: QueueConfig) -> DbResult<Arc<Queue<N>>>
where
N: QueueSpec,
N::Mode: crate::queue::QueueOpen<N>,
{
<N::Mode as crate::queue::QueueOpen<N>>::open(self, config.build()?)
}
pub fn builder(path: impl AsRef<std::path::Path>) -> DbBuilder {
DbBuilder {
path: path.as_ref().to_path_buf(),
workers: WorkerIntervals::default(),
sync_persist: true,
tx_config: crate::txn::TxConfig::default(),
}
}
#[doc = include_str!("../../docs/exclusive-access.md")]
#[doc = include_str!("../../docs/exclusive-access-root.md")]
pub fn open(path: impl AsRef<std::path::Path>) -> DbResult<Self> {
Self::builder(path).open()
}
#[doc = include_str!("../../docs/exclusive-access.md")]
#[doc = include_str!("../../docs/exclusive-access-root.md")]
pub fn open_with_workers(
path: impl AsRef<std::path::Path>,
workers: WorkerIntervals,
) -> DbResult<Self> {
Self::builder(path).workers(workers).open()
}
#[doc = include_str!("../../docs/exclusive-access.md")]
#[doc = include_str!("../../docs/exclusive-access-root.md")]
pub fn open_test(path: impl AsRef<std::path::Path>) -> DbResult<Self> {
Self::builder(path).for_tests().open()
}
pub fn open_with_tx_config(
path: impl AsRef<std::path::Path>,
tx_config: crate::txn::TxConfig,
) -> DbResult<Self> {
Self::builder(path).tx_config(tx_config).open()
}
#[doc = include_str!("../../docs/exclusive-access.md")]
#[doc = include_str!("../../docs/exclusive-access-root.md")]
pub fn open_with_compaction(
path: impl AsRef<std::path::Path>,
compaction_interval: Option<Duration>,
) -> DbResult<Self> {
Self::builder(path)
.workers(WorkerIntervals {
compaction: compaction_interval,
..WorkerIntervals::default()
})
.open()
}
#[doc = include_str!("../../docs/exclusive-access.md")]
#[doc = include_str!("../../docs/exclusive-access-root.md")]
pub fn open_with_intervals(
path: impl AsRef<std::path::Path>,
compaction_interval: Option<Duration>,
flush_interval: Option<Duration>,
) -> DbResult<Self> {
Self::builder(path)
.workers(WorkerIntervals {
compaction: compaction_interval,
flush: flush_interval,
ttl_sweep: Some(DEFAULT_TTL_SWEEP_INTERVAL),
ttl_budget: DEFAULT_TTL_BUDGET,
})
.open()
}
fn open_with_options(
path: impl AsRef<std::path::Path>,
workers: WorkerIntervals,
sync_persist: bool,
tx_config: crate::txn::TxConfig,
) -> DbResult<Self> {
let WorkerIntervals {
compaction: compaction_interval,
flush: flush_interval,
ttl_sweep: ttl_sweep_interval,
ttl_budget,
} = workers;
let path = path.as_ref().to_path_buf();
std::fs::create_dir_all(&path).map_err(crate::DbError::Io)?;
let _lock = Arc::new(crate::lock::acquire(&path)?);
let txlog = Arc::new(crate::txlog::TxLog::open(
&path,
crate::txlog::TxLogConfig {
segment_bytes: tx_config.segment_bytes,
id_window: 4096,
#[cfg(feature = "encryption")]
log_encryption_key: tx_config.log_encryption_key,
},
)?);
let db_info: Persist<DbInfo> = if sync_persist {
Persist::open(path.join("db.info"))?
} else {
Persist::open_no_sync(path.join("db.info"))?
};
let db_info_arc = Arc::new(db_info.clone());
let user_meta = if sync_persist {
Persist::open(path.join("db.user"))?
} else {
Persist::open_no_sync(path.join("db.user"))?
};
let seq = SeqGen::open(path.join("__seq"))?;
let collections: Arc<Mutex<Vec<Arc<dyn Collection>>>> = Arc::new(Mutex::new(Vec::new()));
#[cfg(all(feature = "typed-tree", feature = "armour"))]
let queue_retentions: Arc<Mutex<Vec<Arc<dyn QueueRetentionPass>>>> =
Arc::new(Mutex::new(Vec::new()));
let shutdown = ShutdownSignal::new();
#[cfg(all(feature = "typed-tree", feature = "armour"))]
let queue_wake_service = QueueWakeService::new(shutdown.clone());
let compactor = compaction_interval.map(|interval| {
let cols = collections.clone();
Compactor::start_with_signal(
move || {
let snapshot = lock(&cols).clone();
let mut total = 0;
for c in &snapshot {
total += c.compact()?;
}
Ok(total)
},
interval,
shutdown.clone(),
)
});
let flusher = flush_interval.map(|interval| {
let cols = collections.clone();
Flusher::start_with_signal(
move || {
let snapshot = lock(&cols).clone();
crate::flush_pass::begin();
let started = Instant::now();
let mut first_err = None;
for c in &snapshot {
if let Err(e) = c.periodic_flush()
&& first_err.is_none()
{
first_err = Some(e);
}
}
crate::flush_pass::finish(interval, started.elapsed());
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
},
interval,
shutdown.clone(),
)
});
let sweeper = ttl_sweep_interval.map(|interval| {
let cols = collections.clone();
#[cfg(all(feature = "typed-tree", feature = "armour"))]
let retentions = queue_retentions.clone();
Sweeper::start_with_signal(
move |budget| {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|_| DbError::Internal("system clock before unix epoch"))?
.as_millis() as u64;
let snapshot = lock(&cols).clone();
let mut deleted = 0;
let mut more = false;
let mut first_err = None;
for c in &snapshot {
match ttl_sweep_collection(c, now_ms, budget) {
Ok(out) => {
deleted += out.deleted;
if out.more {
more = true;
}
}
Err(e) if first_err.is_none() => first_err = Some(e),
Err(_) => {}
}
}
#[cfg(all(feature = "typed-tree", feature = "armour"))]
for retention in &*lock(&retentions) {
if let Err(e) = retention.run_retention()
&& first_err.is_none()
{
first_err = Some(e);
}
}
match first_err {
Some(e) => Err(e),
None => Ok(crate::sweeper::SweepPassResult { deleted, more }),
}
},
interval,
ttl_budget,
shutdown.clone(),
)
});
#[cfg(feature = "fjall")]
let opened_clean = {
let was_clean = db_info.cloned().clean;
db_info.update(|i| i.clean = false)?;
was_clean
};
#[cfg(feature = "fjall")]
let managed_lsm_lifetime = Arc::new(ManagedLsmLifetime {
db_info: db_info.clone(),
_root_lease: Arc::clone(&_lock),
});
#[cfg(feature = "fjall")]
let lsm_clean_marker = Arc::new(crate::lsm::ManagedLsmCleanMarker::new(Arc::downgrade(
&managed_lsm_lifetime,
)));
let tx_ranks = Arc::new(Mutex::new(std::collections::HashMap::new()));
let tx_runtime = Arc::new(crate::txn::TxRuntime {
config: tx_config,
log: Arc::clone(&txlog),
collections: Arc::clone(&collections),
ranks: Arc::clone(&tx_ranks),
post_log_queue_producer_permits: Mutex::new(Vec::new()),
_root_lease: Arc::clone(&_lock),
});
Ok(Self {
path,
db_info,
db_info_arc,
user_meta,
seq,
shutdown,
#[cfg(all(feature = "typed-tree", feature = "armour"))]
queue_wake_service,
collections,
#[cfg(all(feature = "typed-tree", feature = "armour"))]
queue_retentions,
inspection_handlers: Arc::new(Mutex::new(BTreeMap::new())),
reserved_names: Mutex::new(std::collections::HashSet::new()),
counter_targets_consumed: Mutex::new(std::collections::HashSet::new()),
#[cfg(feature = "rpc")]
handlers: Arc::new(Mutex::new(Vec::new())),
#[cfg(feature = "rpc")]
rpc_handles: Mutex::new(Vec::new()),
compactor,
flusher,
sweeper,
ttl_budget,
#[cfg(feature = "fjall")]
lsm_db: Mutex::new(None),
#[cfg(feature = "fjall")]
managed_lsm_lifetime,
#[cfg(feature = "fjall")]
lsm_clean_marker,
#[cfg(feature = "fjall")]
opened_clean,
tx_config,
txlog,
tx_replay_done: Mutex::new(std::collections::HashSet::new()),
tx_ranks,
claim_owner_tags: Mutex::new(std::collections::HashMap::new()),
_lock,
tx_runtime,
})
}
pub fn tx(&self) -> crate::txn::Tx<'_> {
crate::txn::Tx {
context: crate::txn::TxContext::Borrowed(self),
limits: crate::txn::TxLimits::from(&self.tx_config),
ops: Vec::new(),
claim_ops: Vec::new(),
durable: self.tx_config.durable_by_default,
after_commit: Vec::new(),
queue_producer_permits: Vec::new(),
queue_depth_admissions: crate::queue::QueueDepthAdmissions::default(),
}
}
pub fn tx_config(&self) -> &crate::txn::TxConfig {
&self.tx_config
}
#[doc(hidden)]
#[cfg(any(test, feature = "test-faults"))]
pub fn tx_debug_skip_apply(on: bool) {
crate::txn::SKIP_APPLY.store(on, std::sync::atomic::Ordering::SeqCst);
}
#[doc(hidden)]
#[cfg(any(test, feature = "test-faults"))]
pub fn tx_debug_skip_projection_reconcile(on: bool) {
crate::txn::SKIP_PROJECTION_RECONCILE.store(on, std::sync::atomic::Ordering::SeqCst);
}
#[doc(hidden)]
#[cfg(any(test, feature = "test-faults"))]
pub fn debug_fail_reconcile_value_read(on: bool) {
crate::projection::FAIL_RECONCILE_VALUE_READ.store(on, std::sync::atomic::Ordering::SeqCst);
}
pub(crate) fn txlog(&self) -> &crate::txlog::TxLog {
&self.txlog
}
pub(crate) fn tx_collection_id(
&self,
participant: &dyn crate::txn::participant::TxParticipant,
) -> DbResult<u32> {
let needle = participant as *const dyn crate::txn::participant::TxParticipant as *const ();
let collections = lock(&self.collections);
collections
.iter()
.position(|c| std::sync::Arc::as_ptr(c) as *const () == needle)
.map(|i| i as u32)
.ok_or(DbError::Client("collection was not opened through this Db"))
}
pub(crate) fn tx_rank_of(
&self,
participant: &dyn crate::txn::participant::TxParticipant,
) -> u8 {
lock(&self.tx_ranks)
.get(participant.name())
.copied()
.unwrap_or(0)
}
pub(crate) fn tx_register_projection_targets(
&self,
targets: &crate::projection::IndexTargets,
) -> DbResult<()> {
for handle in targets.erased_targets() {
let participant =
targets
.resolve_tx_participant(handle.name)
.ok_or(DbError::Internal(
"declared index target missing participant",
))?;
self.tx_collection_id(participant.as_ref()).map_err(|_| {
DbError::Client("secondary index target is not published on this Db")
})?;
lock(&self.tx_ranks).insert(handle.name, 1);
}
Ok(())
}
pub(crate) fn tx_register_counter_targets(
&self,
targets: &crate::counter::CounterTargets,
) -> DbResult<()> {
for member in targets.all() {
let participant = member.as_tx_participant();
self.tx_collection_id(participant.as_ref())
.map_err(|_| DbError::Client("counter target is not published on this Db"))?;
lock(&self.tx_ranks).insert(member.collection_name(), 1);
}
Ok(())
}
pub(crate) fn tx_register_claim_targets(&self, targets: &UniqueTargets) -> DbResult<()> {
for target in targets.all() {
let participant = target.as_tx_participant();
self.tx_collection_id(participant.as_ref()).map_err(|_| {
DbError::Client("unique namespace target is not published on this Db")
})?;
lock(&self.tx_ranks).insert(target.collection_name(), 1);
}
Ok(())
}
pub(crate) fn tx_register_stream_targets(&self, targets: &crate::QueueTargets) -> DbResult<()> {
for participant in targets.tx_participants() {
self.tx_collection_id(participant.as_ref()).map_err(|_| {
DbError::Client("change stream queue target is not published on this Db")
})?;
lock(&self.tx_ranks).insert(participant.name(), 1);
}
Ok(())
}
fn check_stream_open<T, H>(&self, hook: &H) -> DbResult<()>
where
T: CollectionMeta,
H: TypedWriteHook<T::SelfId, T>,
{
if !H::EMITS {
return Ok(());
}
if T::TTL.is_some() {
return Err(DbError::Client("change stream source cannot use TTL"));
}
let targets = hook
.stream_targets()
.ok_or(DbError::Internal("stream carrier missing targets"))?;
targets.check_managed(self).map_err(|_| {
DbError::Client("change stream queue target is not published on this Db")
})?;
Ok(())
}
fn check_claim_owner_tags<T, H>(&self, hook: &H) -> DbResult<()>
where
T: CollectionMeta,
H: TypedWriteHook<T::SelfId, T>,
{
let Some(targets) = hook.claim_targets() else {
return Ok(());
};
let tag = crate::unique::collection_tag(T::NAME);
self.register_claim_owner_tag(tag, T::NAME, targets)
}
fn register_claim_owner_tag(
&self,
tag: u64,
name: &'static str,
targets: &UniqueTargets,
) -> DbResult<()> {
let mut map = lock(&self.claim_owner_tags);
for target in targets.all() {
let namespace = target.collection_name();
match map.entry((namespace, tag)) {
std::collections::hash_map::Entry::Occupied(e) => {
if *e.get() != name {
return Err(DbError::ClaimOwnerTagCollision {
namespace,
a: e.get(),
b: name,
});
}
}
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(name);
}
}
}
Ok(())
}
pub fn pending_transactions(&self) -> Vec<PendingTx> {
self.txlog
.pending()
.into_iter()
.map(|r| PendingTx {
tx_id: r.tx_id,
collections: r.ops.iter().map(|o| o.name.clone()).collect(),
})
.collect()
}
pub(crate) fn note_tx_replay_done(&self, name: &'static str) {
lock(&self.tx_replay_done).insert(name);
self.settle_pending_transactions();
}
fn settle_pending_transactions(&self) {
let done = lock(&self.tx_replay_done);
for record in self.txlog.pending() {
if !self.txlog.is_recovered(record.tx_id) {
continue;
}
let names: std::collections::HashSet<&str> =
record.ops.iter().map(|o| o.name.as_str()).collect();
if names.is_empty() {
continue;
}
if names.iter().all(|n| done.iter().any(|d| d == n)) {
self.txlog.mark_applied(record.tx_id);
}
}
}
pub(crate) fn checkpoint_now(&self) -> DbResult<()> {
let _ = crate::txn::checkpoint::run(self)?;
Ok(())
}
pub fn version(&self) -> u32 {
self.db_info.cloned().version
}
pub fn set_version(&self, f: impl FnOnce(u32) -> u32) -> DbResult<u32> {
let mut new_version = 0;
self.db_info.update(|info| {
new_version = f(info.version);
info.version = new_version;
})?;
Ok(new_version)
}
pub fn db_info(&self) -> DbInfo {
self.db_info.cloned()
}
#[doc(hidden)]
pub fn debug_counters_dirty(&self, name: &str) -> bool {
self.db_info
.cloned()
.collections
.get(name)
.is_some_and(|c| c.counters_dirty)
}
pub fn unique_dirty(&self, name: &str) -> bool {
self.db_info
.cloned()
.collections
.get(name)
.is_some_and(|c| c.unique_dirty)
}
pub(crate) fn counter_dirty_weak(&self) -> Weak<Persist<DbInfo>> {
Arc::downgrade(&self.db_info_arc)
}
#[doc(hidden)]
pub fn db_info_update(&self, f: impl FnOnce(&mut DbInfo)) -> DbResult<()> {
self.db_info.update(f).map_err(Into::into)
}
pub fn metadata<M: DeserializeOwned>(&self) -> Option<M> {
let um = self.user_meta.cloned();
um.db.and_then(|v| serde_json::from_value(v).ok())
}
pub fn set_metadata<M: Serialize + DeserializeOwned>(
&self,
f: impl FnOnce(Option<M>) -> M,
) -> DbResult<M> {
let mut result = None;
let mut ser_err = None;
self.user_meta.update(|um| {
let current = um
.db
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok());
let new_val = f(current);
match serde_json::to_value(&new_val) {
Ok(v) => {
um.db = Some(v);
result = Some(new_val);
}
Err(e) => ser_err = Some(e),
}
})?;
if let Some(e) = ser_err {
return Err(DbError::FormatMismatch(e.to_string()));
}
Ok(result.expect("update executed"))
}
pub fn collection_metadata<M: DeserializeOwned>(&self, name: &str) -> Option<M> {
let um = self.user_meta.cloned();
um.collections
.get(name)
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
pub fn set_collection_metadata<M: Serialize + DeserializeOwned>(
&self,
name: &str,
f: impl FnOnce(Option<M>) -> M,
) -> DbResult<M> {
let mut result = None;
let mut ser_err = None;
self.user_meta.update(|um| {
let current = um
.collections
.get(name)
.and_then(|v| serde_json::from_value(v.clone()).ok());
let new_val = f(current);
match serde_json::to_value(&new_val) {
Ok(v) => {
um.collections.insert(name.to_owned(), v);
result = Some(new_val);
}
Err(e) => ser_err = Some(e),
}
})?;
if let Some(e) = ser_err {
return Err(DbError::FormatMismatch(e.to_string()));
}
Ok(result.expect("update executed"))
}
pub fn tree_path(&self, name: &str) -> PathBuf {
self.path.join(name)
}
pub fn migrate_format<T: CollectionMeta>(
&self,
config: &Config,
) -> DbResult<crate::FormatMigrationReport> {
config.validate()?;
let _reservation = self.reserve_name(T::NAME)?;
let descriptor = StorageDescriptor::Bitcask {
config,
key_len: std::mem::size_of::<T::SelfId>(),
claims: true,
};
descriptor.validate()?;
descriptor.validate_persisted(&self.tree_path(T::NAME))?;
let stored = self.stored_info(T::NAME);
let installed_version = stored.as_ref().map_or(T::VERSION, |info| info.version);
let Some(plan) = build_format_only_plan::<T>(
T::NAME,
stored.as_ref().map(|info| (info.version, info.typ_hash)),
)?
else {
return Ok(crate::FormatMigrationReport::empty(
T::NAME,
installed_version,
));
};
let replay_plan = build_replay_plan::<T::SelfId, T>(
T::NAME,
stored.as_ref().map(|info| (info.version, info.typ_hash)),
&[],
)?;
let meta = TreeMeta::of::<T>();
if let Some(replay_plan) = replay_plan.as_ref() {
self.guard_ambiguous_format_replay(T::NAME, T::TYPE.h(), replay_plan)?;
}
self.validate_format_backend_layout(&meta, &plan, descriptor)?;
if let Some(shard_count) = descriptor.shard_count() {
cleanup_stale_format_temps(&self.tree_path(T::NAME), shard_count)?;
}
self.set_projections_dirty(T::NAME, true)?;
write_collection_marker(
&self.tree_path(T::NAME),
FormatMarker {
from: plan.stored_version,
to: plan.format_to_version,
},
)?;
let mut outcome = self.migrate_format_backend(&meta, &plan, descriptor)?;
outcome
.report
.warnings
.push("explicit migration requires registry-aware unique repair".to_owned());
self.commit_format_outcome(T::NAME, T::TYPE.h(), &plan, true, &outcome)?;
Ok(outcome.report)
}
pub fn migrate_format_fixed<T: CollectionMeta, const V: usize>(
&self,
config: &FixedConfig,
) -> DbResult<crate::FormatMigrationReport> {
config.validate()?;
let _reservation = self.reserve_name(T::NAME)?;
let value_len =
u16::try_from(V).map_err(|_| DbError::Config("fixed value length exceeds u16::MAX"))?;
let descriptor = StorageDescriptor::Fixed {
config,
key_len: std::mem::size_of::<T::SelfId>(),
value_len,
claims: true,
};
descriptor.validate()?;
descriptor.validate_persisted(&self.tree_path(T::NAME))?;
let stored = self.stored_info(T::NAME);
let installed_version = stored.as_ref().map_or(T::VERSION, |info| info.version);
let Some(plan) = build_format_only_plan::<T>(
T::NAME,
stored.as_ref().map(|info| (info.version, info.typ_hash)),
)?
else {
return Ok(crate::FormatMigrationReport::empty(
T::NAME,
installed_version,
));
};
let replay_plan = build_replay_plan::<T::SelfId, T>(
T::NAME,
stored.as_ref().map(|info| (info.version, info.typ_hash)),
&[],
)?;
let meta = TreeMeta::of::<T>();
if let Some(replay_plan) = replay_plan.as_ref() {
self.guard_ambiguous_format_replay(T::NAME, T::TYPE.h(), replay_plan)?;
}
self.validate_format_backend_layout(&meta, &plan, descriptor)?;
if let Some(shard_count) = descriptor.shard_count() {
cleanup_stale_format_temps(&self.tree_path(T::NAME), shard_count)?;
}
self.set_projections_dirty(T::NAME, true)?;
write_collection_marker(
&self.tree_path(T::NAME),
FormatMarker {
from: plan.stored_version,
to: plan.format_to_version,
},
)?;
let mut outcome = self.migrate_format_backend(&meta, &plan, descriptor)?;
outcome
.report
.warnings
.push("explicit migration requires registry-aware unique repair".to_owned());
self.commit_format_outcome(T::NAME, T::TYPE.h(), &plan, true, &outcome)?;
Ok(outcome.report)
}
pub fn next_id(&self, name: &str) -> DbResult<u64> {
self.seq.next_id(name)
}
pub fn collection_len<T: CollectionMeta>(&self) -> u64 {
self.stored_info(T::NAME).map_or(0, |ci| ci.seq)
}
pub fn collection_names(&self) -> Vec<String> {
lock(&self.collections)
.iter()
.map(|c| c.name().to_string())
.collect()
}
pub fn close(&self) -> DbResult<()> {
let collections = lock(&self.collections).clone();
self.finalize(&collections, FinalizeMode::Close)
}
pub fn dirty_shards(&self) -> usize {
lock(&self.collections)
.iter()
.filter_map(|c| c.dirty_shards())
.sum()
}
pub fn clean_shutdown(&self) -> DbResult<()> {
let collections = lock(&self.collections).clone();
self.finalize(&collections, FinalizeMode::CleanShutdown)
}
fn finalize(&self, collections: &[Arc<dyn Collection>], mode: FinalizeMode) -> DbResult<()> {
#[cfg(feature = "fjall")]
let mut lsm_finalization = self.lsm_clean_marker.begin_finalization();
self.disable_all_ttl_sweeps(collections);
let start = Instant::now();
let mut first_err: Option<DbError> = None;
let mut failed = 0usize;
for c in collections.iter() {
match mode {
FinalizeMode::Close => {
if let Err(e) = c.flush() {
tracing::warn!(collection = c.name(), error = %e, "close flush failed");
failed += 1;
note_first_error(&mut first_err, e);
}
if let Err(e) = c.durable_flush() {
tracing::warn!(collection = c.name(), error = %e, "close durable_flush failed");
failed += 1;
note_first_error(&mut first_err, e);
}
}
FinalizeMode::CleanShutdown => {
if let Err(e) = c.clean_shutdown() {
tracing::warn!(collection = c.name(), error = %e, "clean_shutdown failed");
failed += 1;
note_first_error(&mut first_err, e);
}
}
}
if let Err(e) = self.save_collection_len(c.name(), c.len() as u64) {
tracing::warn!(collection = c.name(), error = %e, "finalization save_collection_len failed");
failed += 1;
note_first_error(&mut first_err, e);
}
}
if first_err.is_none()
&& let Err(e) = self.clear_consumed_counter_flags(collections)
{
failed += 1;
note_first_error(&mut first_err, e);
}
if let Err(e) = self.seq.flush() {
tracing::warn!(error = %e, "seq flush failed");
failed += 1;
note_first_error(&mut first_err, e);
}
let pending = self.pending_transactions();
if !pending.is_empty() {
tracing::warn!(
count = pending.len(),
"finalizing with pending transactions still in the log"
);
}
if let Err(e) = self.checkpoint_now() {
tracing::warn!(error = %e, "finalization checkpoint failed");
failed += 1;
note_first_error(&mut first_err, e);
}
#[cfg(feature = "fjall")]
if first_err.is_none() {
match self.db_info.update(|i| i.clean = true) {
Ok(()) => lsm_finalization.note_clean_persisted(),
Err(e) => {
tracing::warn!(error = %e, "finalization db_info clean flag failed");
failed += 1;
note_first_error(&mut first_err, e.into());
}
}
}
#[cfg(feature = "fjall")]
if first_err.is_none() {
lsm_finalization.publish_clean();
} else if let Err(e) = lsm_finalization.finish_dirty() {
tracing::warn!(error = %e, "finalization LSM clean-marker dirty publish failed");
failed += 1;
note_first_error(&mut first_err, e);
}
tracing::info!(
collections = collections.len(),
failed,
elapsed_ms = start.elapsed().as_millis() as u64,
mode = match mode {
FinalizeMode::Close => "close",
FinalizeMode::CleanShutdown => "clean_shutdown",
},
"db finalization"
);
first_err.map_or(Ok(()), Err)
}
pub fn shutdown(&mut self) -> DbResult<()> {
self.stop_workers();
let result = self.clean_shutdown();
self.close_all_watches();
result
}
fn stop_workers(&mut self) {
self.shutdown.shutdown();
#[cfg(all(feature = "typed-tree", feature = "armour"))]
self.queue_wake_service.join();
#[cfg(feature = "rpc")]
for h in lock(&self.rpc_handles).drain(..) {
drop(h); }
if let Some(ref mut c) = self.compactor {
c.stop();
}
if let Some(ref mut f) = self.flusher {
f.stop();
}
if let Some(ref mut s) = self.sweeper {
s.stop();
}
}
fn close_all_watches(&self) {
let collections = lock(&self.collections).clone();
for collection in collections {
collection.close_watches();
}
}
fn disable_all_ttl_sweeps(&self, collections: &[Arc<dyn Collection>]) {
if collections.is_empty() {
return;
}
tracing::info!(
collections = collections.len(),
"ttl sweep disabled for finalization; re-enable per collection with \
Db::set_ttl_sweep(name, true) if this Db keeps running"
);
for c in collections.iter() {
if let Err(e) = c.set_ttl_sweep(false) {
tracing::warn!(
collection = c.name(),
error = %e,
"disable ttl sweep failed"
);
}
}
}
pub fn shutdown_signal(&self) -> ShutdownSignal {
self.shutdown.clone()
}
pub fn compact(&self) -> DbResult<usize> {
let snapshot = lock(&self.collections).clone();
let mut total = 0;
for c in &snapshot {
total += c.compact()?;
}
Ok(total)
}
#[cfg(all(feature = "typed-tree", feature = "armour"))]
pub(crate) fn register_queue_retention(&self, retention: Arc<dyn QueueRetentionPass>) {
lock(&self.queue_retentions).push(retention);
}
pub fn sweep_expired_now(&self) -> DbResult<usize> {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|_| DbError::Internal("system clock before unix epoch"))?
.as_millis() as u64;
let snapshot = lock(&self.collections).clone();
let mut total_deleted = 0;
let mut first_err = None;
for c in &snapshot {
match ttl_sweep_collection(c, now_ms, self.ttl_budget) {
Ok(out) => total_deleted += out.deleted,
Err(e) if first_err.is_none() => first_err = Some(e),
Err(_) => {}
}
}
#[cfg(all(feature = "typed-tree", feature = "armour"))]
for retention in &*lock(&self.queue_retentions) {
if let Err(e) = retention.run_retention()
&& first_err.is_none()
{
first_err = Some(e);
}
}
match first_err {
Some(e) => Err(e),
None => Ok(total_deleted),
}
}
pub fn set_ttl_sweep(&self, name: &str, enabled: bool) -> DbResult<()> {
self.collection_by_name(name)?.set_ttl_sweep(enabled)
}
pub fn rebuild_ttl_index(&self, name: &str) -> DbResult<()> {
self.collection_by_name(name)?.rebuild_ttl_index()
}
pub fn recount_counters(&self, collection: &str) -> DbResult<u64> {
let coll = self.collection_by_name(collection)?;
if coll.counter_target_names().is_empty() {
return Err(DbError::NotACounterSource {
collection: collection.to_string(),
});
}
coll.reconcile_counters()
}
fn collection_by_name(&self, name: &str) -> DbResult<Arc<dyn Collection>> {
let cols = lock(&self.collections);
cols.iter()
.find(|c| c.name() == name)
.cloned()
.ok_or(DbError::Client("unknown collection"))
}
#[doc = include_str!("../../docs/exclusive-access.md")]
pub fn open_typed_tree<T, C, H>(
&self,
config: Config,
hook: H,
migrations: &[TypedMigration<T::SelfId, T>],
) -> DbResult<Arc<TypedTree<T::SelfId, T, C, TtlHook<T, H>>>>
where
T: CollectionMeta + Clone + Send + Sync + 'static,
T::SelfId: Key + Ord + Send + Sync,
C: Codec<T> + Default + 'static,
H: TypedWriteHook<T::SelfId, T> + 'static,
{
self.check_stream_open::<T, H>(&hook)?;
crate::unique::check_no_ttl_claims::<T, H>(&hook)?;
crate::unique::check_claim_key_width::<T, H>(&hook)?;
self.check_claim_owner_tags::<T, H>(&hook)?;
let hook = TtlHook::<T, H>::new(hook);
let format_config = config.clone();
let claims = H::CLAIMS
|| hook
.claim_targets()
.is_some_and(|targets| !targets.is_empty());
self.open_managed(
|meta| TypedTree::open_inner(self.tree_path(meta.name), config, C::default(), hook),
crate::access::CodecWire(C::default()),
migrations,
StorageDescriptor::Bitcask {
config: &format_config,
key_len: std::mem::size_of::<T::SelfId>(),
claims,
},
)
}
#[doc = include_str!("../../docs/exclusive-access.md")]
pub fn open_typed_map<T, C, H>(
&self,
config: Config,
hook: H,
migrations: &[TypedMigration<T::SelfId, T>],
) -> DbResult<Arc<TypedMap<T::SelfId, T, C, TtlHook<T, H>>>>
where
T: CollectionMeta + Clone + Send + Sync + 'static,
T::SelfId: Key + Send + Sync + Hash + Eq,
C: Codec<T> + Default + 'static,
H: TypedWriteHook<T::SelfId, T> + 'static,
{
self.check_stream_open::<T, H>(&hook)?;
crate::unique::check_no_ttl_claims::<T, H>(&hook)?;
crate::unique::check_claim_key_width::<T, H>(&hook)?;
self.check_claim_owner_tags::<T, H>(&hook)?;
let hook = TtlHook::<T, H>::new(hook);
let format_config = config.clone();
let claims = H::CLAIMS
|| hook
.claim_targets()
.is_some_and(|targets| !targets.is_empty());
self.open_managed(
|meta| TypedMap::open_inner(self.tree_path(meta.name), config, C::default(), hook),
crate::access::CodecWire(C::default()),
migrations,
StorageDescriptor::Bitcask {
config: &format_config,
key_len: std::mem::size_of::<T::SelfId>(),
claims,
},
)
}
#[doc = include_str!("../../docs/exclusive-access.md")]
#[cfg(feature = "var-collections")]
pub fn open_var_typed_tree<T, C, H>(
&self,
config: Config,
hook: H,
migrations: &[TypedMigration<T::SelfId, T>],
) -> DbResult<Arc<crate::VarTypedTree<T::SelfId, T, C, TtlHook<T, H>>>>
where
T: CollectionMeta + Clone + Send + Sync + 'static,
T::SelfId: Key + Ord + Send + Sync,
C: Codec<T> + Clone + Default + 'static,
H: TypedWriteHook<T::SelfId, T> + 'static,
{
self.check_stream_open::<T, H>(&hook)?;
crate::unique::check_no_ttl_claims::<T, H>(&hook)?;
crate::unique::check_claim_key_width::<T, H>(&hook)?;
self.check_claim_owner_tags::<T, H>(&hook)?;
let hook = TtlHook::<T, H>::new(hook);
let format_config = config.clone();
let claims = H::CLAIMS
|| hook
.claim_targets()
.is_some_and(|targets| !targets.is_empty());
self.open_managed(
|meta| {
crate::VarTypedTree::open_hooked_inner(
self.tree_path(meta.name),
config,
C::default(),
hook,
)
},
crate::access::CodecWire(C::default()),
migrations,
StorageDescriptor::Bitcask {
config: &format_config,
key_len: std::mem::size_of::<T::SelfId>(),
claims,
},
)
}
#[doc = include_str!("../../docs/exclusive-access.md")]
#[cfg(feature = "var-collections")]
pub fn open_var_typed_map<T, C, H>(
&self,
config: Config,
hook: H,
migrations: &[TypedMigration<T::SelfId, T>],
) -> DbResult<Arc<crate::VarTypedMap<T::SelfId, T, C, TtlHook<T, H>>>>
where
T: CollectionMeta + Clone + Send + Sync + 'static,
T::SelfId: Key + Send + Sync + Hash + Eq,
C: Codec<T> + Clone + Default + 'static,
H: TypedWriteHook<T::SelfId, T> + 'static,
{
self.check_stream_open::<T, H>(&hook)?;
crate::unique::check_no_ttl_claims::<T, H>(&hook)?;
crate::unique::check_claim_key_width::<T, H>(&hook)?;
self.check_claim_owner_tags::<T, H>(&hook)?;
let hook = TtlHook::<T, H>::new(hook);
let format_config = config.clone();
let claims = H::CLAIMS
|| hook
.claim_targets()
.is_some_and(|targets| !targets.is_empty());
self.open_managed(
|meta| {
crate::VarTypedMap::open_hooked_inner(
self.tree_path(meta.name),
config,
C::default(),
hook,
)
},
crate::access::CodecWire(C::default()),
migrations,
StorageDescriptor::Bitcask {
config: &format_config,
key_len: std::mem::size_of::<T::SelfId>(),
claims,
},
)
}
#[doc = include_str!("../../docs/exclusive-access.md")]
pub fn open_zero_tree<T, const V: usize, H>(
&self,
config: Config,
hook: H,
migrations: &[TypedMigration<T::SelfId, T>],
) -> DbResult<Arc<ZeroTree<T::SelfId, V, T, TtlHook<T, H>, crate::durability::Bitcask>>>
where
T: CollectionMeta
+ Copy
+ zerocopy::IntoBytes
+ zerocopy::FromBytes
+ zerocopy::Immutable
+ Send
+ Sync
+ 'static,
T::SelfId: Key + Ord + Send + Sync,
H: TypedWriteHook<T::SelfId, T> + 'static,
{
self.check_stream_open::<T, H>(&hook)?;
crate::unique::check_no_ttl_claims::<T, H>(&hook)?;
crate::unique::check_claim_key_width::<T, H>(&hook)?;
self.check_claim_owner_tags::<T, H>(&hook)?;
let hook = TtlHook::<T, H>::new(hook);
let format_config = config.clone();
let claims = H::CLAIMS
|| hook
.claim_targets()
.is_some_and(|targets| !targets.is_empty());
self.open_managed(
|meta| ZeroTree::open_hooked_inner(self.tree_path(meta.name), config, hook),
crate::access::ZeroWire::<V>,
migrations,
StorageDescriptor::Bitcask {
config: &format_config,
key_len: std::mem::size_of::<T::SelfId>(),
claims,
},
)
}
pub(crate) fn open_zero_tree_fixed_internal<T, const V: usize, H>(
&self,
config: FixedConfig,
hook: H,
migrations: &[TypedMigration<T::SelfId, T>],
after_stage: impl FnOnce(
&Arc<ZeroTree<T::SelfId, V, T, TtlHook<T, H>, crate::durability::Fixed>>,
) -> DbResult<()>,
) -> DbResult<Arc<ZeroTree<T::SelfId, V, T, TtlHook<T, H>, crate::durability::Fixed>>>
where
T: CollectionMeta
+ Copy
+ zerocopy::IntoBytes
+ zerocopy::FromBytes
+ zerocopy::Immutable
+ Send
+ Sync
+ 'static,
T::SelfId: Key + Ord + Send + Sync,
H: TypedWriteHook<T::SelfId, T> + 'static,
{
self.open_zero_tree_fixed_with_access(
config,
hook,
migrations,
ManagedCollectionAccess::Internal,
after_stage,
)
}
fn open_zero_tree_fixed_with_access<T, const V: usize, H>(
&self,
config: FixedConfig,
hook: H,
migrations: &[TypedMigration<T::SelfId, T>],
access: ManagedCollectionAccess,
after_stage: impl FnOnce(
&Arc<ZeroTree<T::SelfId, V, T, TtlHook<T, H>, crate::durability::Fixed>>,
) -> DbResult<()>,
) -> DbResult<Arc<ZeroTree<T::SelfId, V, T, TtlHook<T, H>, crate::durability::Fixed>>>
where
T: CollectionMeta
+ Copy
+ zerocopy::IntoBytes
+ zerocopy::FromBytes
+ zerocopy::Immutable
+ Send
+ Sync
+ 'static,
T::SelfId: Key + Ord + Send + Sync,
H: TypedWriteHook<T::SelfId, T> + 'static,
{
self.check_stream_open::<T, H>(&hook)?;
crate::unique::check_no_ttl_claims::<T, H>(&hook)?;
crate::unique::check_claim_key_width::<T, H>(&hook)?;
self.check_claim_owner_tags::<T, H>(&hook)?;
let hook = TtlHook::<T, H>::new(hook);
let format_config = config.clone();
let claims = H::CLAIMS
|| hook
.claim_targets()
.is_some_and(|targets| !targets.is_empty());
let value_len =
u16::try_from(V).map_err(|_| DbError::Config("fixed value length exceeds u16::MAX"))?;
self.open_managed_with_access_after_stage(
|meta| ZeroTree::open_with_hook_inner(self.tree_path(meta.name), config, hook),
crate::access::ZeroWire::<V>,
migrations,
StorageDescriptor::Fixed {
config: &format_config,
key_len: std::mem::size_of::<T::SelfId>(),
value_len,
claims,
},
access,
after_stage,
)
}
#[doc = include_str!("../../docs/exclusive-access.md")]
pub fn open_zero_tree_fixed<T, const V: usize, H>(
&self,
config: FixedConfig,
hook: H,
migrations: &[TypedMigration<T::SelfId, T>],
) -> DbResult<Arc<ZeroTree<T::SelfId, V, T, TtlHook<T, H>, crate::durability::Fixed>>>
where
T: CollectionMeta
+ Copy
+ zerocopy::IntoBytes
+ zerocopy::FromBytes
+ zerocopy::Immutable
+ Send
+ Sync
+ 'static,
T::SelfId: Key + Ord + Send + Sync,
H: TypedWriteHook<T::SelfId, T> + 'static,
{
self.open_zero_tree_fixed_with_access(
config,
hook,
migrations,
ManagedCollectionAccess::Public,
|_| Ok(()),
)
}
#[doc = include_str!("../../docs/exclusive-access.md")]
pub fn open_zero_map<T, const V: usize, H>(
&self,
config: Config,
hook: H,
migrations: &[TypedMigration<T::SelfId, T>],
) -> DbResult<Arc<ZeroMap<T::SelfId, V, T, TtlHook<T, H>, crate::durability::Bitcask>>>
where
T: CollectionMeta
+ Copy
+ zerocopy::IntoBytes
+ zerocopy::FromBytes
+ zerocopy::Immutable
+ Send
+ Sync
+ 'static,
T::SelfId: Key + Send + Sync + Hash + Eq,
H: TypedWriteHook<T::SelfId, T> + 'static,
{
self.check_stream_open::<T, H>(&hook)?;
crate::unique::check_no_ttl_claims::<T, H>(&hook)?;
crate::unique::check_claim_key_width::<T, H>(&hook)?;
self.check_claim_owner_tags::<T, H>(&hook)?;
let hook = TtlHook::<T, H>::new(hook);
let format_config = config.clone();
let claims = H::CLAIMS
|| hook
.claim_targets()
.is_some_and(|targets| !targets.is_empty());
self.open_managed(
|meta| ZeroMap::open_hooked_inner(self.tree_path(meta.name), config, hook),
crate::access::ZeroWire::<V>,
migrations,
StorageDescriptor::Bitcask {
config: &format_config,
key_len: std::mem::size_of::<T::SelfId>(),
claims,
},
)
}
#[doc = include_str!("../../docs/exclusive-access.md")]
pub fn open_zero_map_fixed<T, const V: usize, H>(
&self,
config: FixedConfig,
hook: H,
migrations: &[TypedMigration<T::SelfId, T>],
) -> DbResult<Arc<ZeroMap<T::SelfId, V, T, TtlHook<T, H>, crate::durability::Fixed>>>
where
T: CollectionMeta
+ Copy
+ zerocopy::IntoBytes
+ zerocopy::FromBytes
+ zerocopy::Immutable
+ Send
+ Sync
+ 'static,
T::SelfId: Key + Send + Sync + Hash + Eq,
H: TypedWriteHook<T::SelfId, T> + 'static,
{
self.check_stream_open::<T, H>(&hook)?;
crate::unique::check_no_ttl_claims::<T, H>(&hook)?;
crate::unique::check_claim_key_width::<T, H>(&hook)?;
self.check_claim_owner_tags::<T, H>(&hook)?;
let hook = TtlHook::<T, H>::new(hook);
let format_config = config.clone();
let claims = H::CLAIMS
|| hook
.claim_targets()
.is_some_and(|targets| !targets.is_empty());
let value_len =
u16::try_from(V).map_err(|_| DbError::Config("fixed value length exceeds u16::MAX"))?;
self.open_managed(
|meta| ZeroMap::open_with_hook_inner(self.tree_path(meta.name), config, hook),
crate::access::ZeroWire::<V>,
migrations,
StorageDescriptor::Fixed {
config: &format_config,
key_len: std::mem::size_of::<T::SelfId>(),
value_len,
claims,
},
)
}
#[doc = include_str!("../../docs/exclusive-access.md")]
pub fn open_unique_index<N: UniqueNamespace>(
&self,
config: FixedConfig,
) -> DbResult<Arc<UniqueIndex<N>>>
where
N::SelfId: Key + Send + Sync + Hash + Eq,
{
let inner =
self.open_zero_map_fixed::<N, { size_of::<UniqueRow>() }, NoHook>(config, NoHook, &[])?;
Ok(Arc::new(UniqueIndex::from_inner(inner)))
}
#[cfg(feature = "fjall")]
fn lsm_database(
&self,
db_config: Option<crate::lsm::LsmDbConfig>,
) -> DbResult<(fjall::Database, crate::lsm::PersistPolicy)> {
let mut guard = lock(&self.lsm_db);
if let Some((db, existing)) = guard.as_ref() {
if let Some(req) = db_config
&& *existing != req
{
return Err(DbError::Config(
"LsmDbConfig differs from the already-open shared fjall Database (first-open-wins)",
));
}
return Ok((db.clone(), existing.persist));
}
let cfg = db_config.unwrap_or_default();
let root = self.path.join("lsm");
let db = crate::lsm::map_fjall(
fjall::Database::builder(&root)
.cache_size(cfg.cache_size)
.open(),
)?;
*guard = Some((db.clone(), cfg));
Ok((db, cfg.persist))
}
#[cfg(feature = "fjall")]
pub fn open_lsm_tree<T, C, H>(
&self,
db_config: Option<crate::lsm::LsmDbConfig>,
config: crate::lsm::LsmConfig,
hook: H,
migrations: &[TypedMigration<T::SelfId, T>],
) -> DbResult<Arc<crate::lsm::LsmTree<T::SelfId, T, C, H>>>
where
T: CollectionMeta + Clone + Send + Sync + 'static,
T::SelfId: Key + Ord + Send + Sync + 'static,
C: Codec<T> + Clone + Default + 'static,
H: TypedWriteHook<T::SelfId, T> + 'static,
{
self.check_stream_open::<T, H>(&hook)?;
if H::WATCHES {
return Err(DbError::WatchUnsupported);
}
self.open_managed(
|meta| {
let (fdb, persist) = self.lsm_database(db_config)?;
let initial_count = if self.opened_clean {
self.stored_info(meta.name).as_ref().map(|i| i.seq)
} else {
None
};
crate::lsm::LsmTree::<T::SelfId, T, C, H>::open_with_persist(
fdb,
config,
persist,
C::default(),
hook,
initial_count,
Arc::clone(&self.lsm_clean_marker),
)
},
crate::access::CodecWire(C::default()),
migrations,
StorageDescriptor::Lsm,
)
}
fn stored_info(&self, name: &str) -> Option<CollectionInfo> {
self.db_info.cloned().collections.get(name).cloned()
}
#[cfg(all(test, feature = "fjall"))]
pub(crate) fn has_lsm_database_for_test(&self) -> bool {
lock(&self.lsm_db).is_some()
}
fn reserve_name(&self, name: &'static str) -> DbResult<NameReservation<'_>> {
if RESERVED_COLLECTION_NAMES.contains(&name) {
return Err(DbError::DuplicateCollection(name));
}
let mut reserved = lock(&self.reserved_names);
if lock(&self.collections).iter().any(|c| c.name() == name) {
return Err(DbError::DuplicateCollection(name));
}
if !reserved.insert(name) {
return Err(DbError::DuplicateCollection(name));
}
Ok(NameReservation { db: self, name })
}
fn open_managed<T, S, W>(
&self,
open: impl FnOnce(&TreeMeta) -> DbResult<S>,
wire: W,
migrations: &[TypedMigration<T::SelfId, T>],
descriptor: StorageDescriptor<'_>,
) -> DbResult<Arc<S>>
where
T: CollectionMeta + Send + Sync + 'static,
S: crate::access::Access<Key = T::SelfId, Val = T>
+ crate::access::ManagedOpen<Key = T::SelfId, Val = T>
+ Collection
+ 'static,
W: crate::access::Wire<T>,
{
descriptor.validate()?;
self.open_managed_with_access(
open,
wire,
migrations,
descriptor,
ManagedCollectionAccess::Public,
)
}
fn open_managed_with_access<T, S, W>(
&self,
open: impl FnOnce(&TreeMeta) -> DbResult<S>,
wire: W,
migrations: &[TypedMigration<T::SelfId, T>],
descriptor: StorageDescriptor<'_>,
access: ManagedCollectionAccess,
) -> DbResult<Arc<S>>
where
T: CollectionMeta + Send + Sync + 'static,
S: crate::access::Access<Key = T::SelfId, Val = T>
+ crate::access::ManagedOpen<Key = T::SelfId, Val = T>
+ Collection
+ 'static,
W: crate::access::Wire<T>,
{
self.open_managed_with_access_after_stage(
open,
wire,
migrations,
descriptor,
access,
|_| Ok(()),
)
}
fn open_managed_with_access_after_stage<T, S, W>(
&self,
open: impl FnOnce(&TreeMeta) -> DbResult<S>,
wire: W,
migrations: &[TypedMigration<T::SelfId, T>],
descriptor: StorageDescriptor<'_>,
access: ManagedCollectionAccess,
after_stage: impl FnOnce(&Arc<S>) -> DbResult<()>,
) -> DbResult<Arc<S>>
where
T: CollectionMeta + Send + Sync + 'static,
S: crate::access::Access<Key = T::SelfId, Val = T>
+ crate::access::ManagedOpen<Key = T::SelfId, Val = T>
+ Collection
+ 'static,
W: crate::access::Wire<T>,
{
descriptor.validate()?;
let meta = TreeMeta::of::<T>();
let staged = self.stage_managed(
open,
wire,
migrations,
descriptor,
self.reserve_name(meta.name)?,
)?;
after_stage(&staged.collection)?;
let collection = Arc::clone(&staged.collection);
self.publish_staged_adapters(vec![self.stage_to_adapter(staged, access)])?;
Ok(collection)
}
#[allow(clippy::too_many_arguments)] pub(crate) fn open_managed_pair<T1, S1, W1, T2, S2, W2>(
&self,
open_first: impl FnOnce(&TreeMeta) -> DbResult<S1>,
wire_first: W1,
migrations_first: &[TypedMigration<T1::SelfId, T1>],
descriptor_first: StorageDescriptor<'_>,
open_second: impl FnOnce(&TreeMeta) -> DbResult<S2>,
wire_second: W2,
migrations_second: &[TypedMigration<T2::SelfId, T2>],
descriptor_second: StorageDescriptor<'_>,
after_stage: impl FnOnce(&Arc<S1>, &Arc<S2>) -> DbResult<()>,
first_access: ManagedCollectionAccess,
second_access: ManagedCollectionAccess,
) -> DbResult<(Arc<S1>, Arc<S2>)>
where
T1: CollectionMeta + Send + Sync + 'static,
S1: crate::access::Access<Key = T1::SelfId, Val = T1>
+ crate::access::ManagedOpen<Key = T1::SelfId, Val = T1>
+ Collection
+ 'static,
W1: crate::access::Wire<T1>,
T2: CollectionMeta + Send + Sync + 'static,
S2: crate::access::Access<Key = T2::SelfId, Val = T2>
+ crate::access::ManagedOpen<Key = T2::SelfId, Val = T2>
+ Collection
+ 'static,
W2: crate::access::Wire<T2>,
{
descriptor_first.validate()?;
descriptor_second.validate()?;
let first_meta = TreeMeta::of::<T1>();
let second_meta = TreeMeta::of::<T2>();
descriptor_first.validate_persisted(&self.tree_path(first_meta.name))?;
descriptor_second.validate_persisted(&self.tree_path(second_meta.name))?;
let first_guard = self.reserve_name(first_meta.name)?;
let second_guard = self.reserve_name(second_meta.name)?;
let first = self.stage_managed(
open_first,
wire_first,
migrations_first,
descriptor_first,
first_guard,
)?;
let second = self.stage_managed(
open_second,
wire_second,
migrations_second,
descriptor_second,
second_guard,
)?;
after_stage(&first.collection, &second.collection)?;
let collections = (
Arc::clone(&first.collection),
Arc::clone(&second.collection),
);
self.publish_staged_adapters(vec![
self.stage_to_adapter(first, first_access),
self.stage_to_adapter(second, second_access),
])?;
Ok(collections)
}
fn stage_managed<'a, T, S, W>(
&self,
open: impl FnOnce(&TreeMeta) -> DbResult<S>,
wire: W,
migrations: &[TypedMigration<T::SelfId, T>],
descriptor: StorageDescriptor<'_>,
name_guard: NameReservation<'a>,
) -> DbResult<StagedManaged<'a, S, W>>
where
T: CollectionMeta + Send + Sync + 'static,
S: crate::access::Access<Key = T::SelfId, Val = T>
+ crate::access::ManagedOpen<Key = T::SelfId, Val = T>
+ Collection
+ 'static,
W: crate::access::Wire<T>,
{
let meta = TreeMeta::of::<T>();
descriptor.validate_persisted(&self.tree_path(meta.name))?;
let mut stored = self.stored_info(meta.name);
let has_pending_tx_ops = self
.txlog()
.pending()
.iter()
.any(|record| record.ops.iter().any(|op| op.name == meta.name));
let deferred_typ_hash = stored.as_ref().and_then(|info| {
let expected = meta.ty.h();
let candidate = info.version == meta.version
&& info.typ_hash != expected
&& !self.tx_config().discard_pending_on_schema_mismatch
&& self.txlog().pending().iter().any(|record| {
record.ops.iter().any(|op| {
op.name == meta.name
&& matches!(op.kind, crate::txlog::record::OpKind::Put)
&& op.schema_fingerprint != info.typ_hash
})
});
candidate.then_some((info.typ_hash, expected))
});
let migration_plan = if deferred_typ_hash.is_some() {
None
} else {
build_plan::<T::SelfId, T>(
meta.name,
stored.as_ref().map(|info| (info.version, info.typ_hash)),
migrations,
)?
};
if let Some(plan) = migration_plan
.as_ref()
.filter(|plan| !plan.format_steps.is_empty())
{
crate::format_migration::refuse_if_required(descriptor.policy(), meta.name, plan)?;
if matches!(descriptor, StorageDescriptor::Lsm) {
return Err(DbError::FormatMigrationUnsupported {
collection: meta.name,
backend: "lsm",
});
}
}
let replay_plan = if deferred_typ_hash.is_some() {
None
} else {
build_replay_plan::<T::SelfId, T>(
meta.name,
stored.as_ref().map(|info| (info.version, info.typ_hash)),
migrations,
)?
};
if has_pending_tx_ops && let Some(replay_plan) = replay_plan.as_ref() {
self.guard_ambiguous_format_replay(meta.name, meta.ty.h(), replay_plan)?;
}
if let Some(plan) = migration_plan
.as_ref()
.filter(|plan| !plan.format_steps.is_empty())
{
self.validate_format_backend_layout(&meta, plan, descriptor)?;
}
if let Some(shard_count) = descriptor.shard_count() {
cleanup_stale_format_temps(&self.tree_path(meta.name), shard_count)?;
}
self.remove_committed_format_marker(meta.name, stored.as_ref())?;
if let Some(plan) = migration_plan
.as_ref()
.filter(|plan| !plan.format_steps.is_empty())
{
self.set_projections_dirty(meta.name, true)?;
write_collection_marker(
&self.tree_path(meta.name),
FormatMarker {
from: plan.stored_version,
to: plan.format_to_version,
},
)?;
let outcome = self.migrate_format_backend(&meta, plan, descriptor)?;
self.commit_format_outcome(
meta.name,
meta.ty.h(),
plan,
descriptor.claims(),
&outcome,
)?;
stored = self.stored_info(meta.name);
}
let coll = open(&meta)?;
let coll = Arc::new(coll);
S::bind_tx_arc(&coll);
if let Some(info) = stored.as_ref() {
crate::access::ManagedOpen::floor_recovered_gsns(
&*coll,
info.gsn_high,
&info.shard_gsn_high,
);
}
let replayed = coll.replay_pending_tx_log(
self,
stored.as_ref().map(|info| info.typ_hash),
replay_plan.as_ref(),
)?;
if let Some((stored, expected)) = deferred_typ_hash {
return Err(DbError::SchemaMismatch {
name: meta.name.to_owned(),
kind: SchemaMismatchKind::TypHash { stored, expected },
});
}
if replayed > 0 {
tracing::info!(
collection = meta.name,
replayed,
"replayed pending transaction operations"
);
}
let mut projections_dirty = stored.as_ref().is_some_and(|info| info.projections_dirty);
if Self::migration_will_run(stored.as_ref(), &meta) && coll.projections_enabled() {
projections_dirty = true;
self.set_projections_dirty(meta.name, true)?;
}
self.run_migration(
&meta,
stored.as_ref(),
migrations,
|mfn| coll.migrate_inner(mfn, false),
|| coll.flush_for_migration(),
)?;
let skip_reconcile = {
#[cfg(any(test, feature = "test-faults"))]
{
crate::txn::SKIP_PROJECTION_RECONCILE.load(std::sync::atomic::Ordering::SeqCst)
}
#[cfg(not(any(test, feature = "test-faults")))]
{
false
}
};
if projections_dirty && !skip_reconcile {
coll.reconcile_projections()?;
projections_dirty = false;
}
coll.replay_init();
coll.build_ttl_index()?;
let target_names = crate::access::ManagedOpen::counter_target_names(&*coll);
let mut counters_dirty = self
.stored_info(meta.name)
.is_some_and(|i| i.counters_dirty);
if !target_names.is_empty() {
let dirty_targets: Vec<&'static str> = target_names
.iter()
.copied()
.filter(|n| self.stored_info(n).is_some_and(|i| i.counters_dirty))
.collect();
if counters_dirty || !dirty_targets.is_empty() {
let repaired = crate::access::ManagedOpen::reconcile_counters(&*coll)?;
tracing::info!(collection = meta.name, repaired, "counters recounted");
self.clear_counters_dirty(meta.name)?;
counters_dirty = false;
lock(&self.counter_targets_consumed).extend(dirty_targets);
}
}
self.save_info(&meta, coll.len() as u64, projections_dirty, counters_dirty)?;
coll.bind_counter_dirty(self, meta.name);
if !target_names.is_empty() {
crate::access::ManagedOpen::mark_counter_dirty(&*coll)?;
}
coll.register_tx_metadata(self)?;
Ok(StagedManaged {
meta,
collection: coll,
wire,
_name_guard: name_guard,
})
}
fn migrate_format_backend(
&self,
meta: &TreeMeta,
plan: &MigrationPlan,
descriptor: StorageDescriptor<'_>,
) -> DbResult<BackendMigrationOutcome> {
let dir = self.tree_path(meta.name);
match descriptor {
StorageDescriptor::Bitcask {
config, key_len, ..
} => crate::format_migration::bitcask::migrate_bitcask(
&dir,
meta.name,
plan.stored_version,
plan,
config,
key_len,
),
StorageDescriptor::Fixed {
config,
key_len,
value_len,
..
} => migrate_fixed(
&dir,
meta.name,
plan.stored_version,
plan,
config,
key_len,
value_len,
),
StorageDescriptor::Lsm => Err(DbError::FormatMigrationUnsupported {
collection: meta.name,
backend: "lsm",
}),
}
}
fn validate_format_backend_layout(
&self,
meta: &TreeMeta,
plan: &MigrationPlan,
descriptor: StorageDescriptor<'_>,
) -> DbResult<()> {
let shard_count = match descriptor {
StorageDescriptor::Bitcask { config, .. } => config.shard_count,
StorageDescriptor::Fixed { config, .. } => config.shard_count,
StorageDescriptor::Lsm => return Ok(()),
};
crate::format_migration::marker::configured_shards(
&self.tree_path(meta.name),
shard_count,
plan.stored_version,
plan.format_to_version,
)?;
Ok(())
}
fn guard_ambiguous_format_replay(
&self,
name: &'static str,
target_fingerprint: u64,
plan: &MigrationPlan,
) -> DbResult<()> {
let pending = self.txlog().pending();
let ambiguous = |op: &crate::txlog::record::TxOp| {
op.name == name
&& plan.logged_fingerprint_is_ambiguous(op.schema_fingerprint, target_fingerprint)
};
let Some((first_record, first_op)) = pending.iter().find_map(|record| {
record
.ops
.iter()
.find(|op| ambiguous(op))
.map(|op| (record, op))
}) else {
return Ok(());
};
if !self.tx_config().discard_pending_on_schema_mismatch {
return Err(DbError::TxFormatAmbiguous {
collection: name,
tx_id: first_record.tx_id,
fingerprint: first_op.schema_fingerprint,
});
}
let mut discarded = 0usize;
for record in pending {
if record.ops.iter().any(&ambiguous) {
self.txlog().mark_applied(record.tx_id);
discarded += 1;
}
}
let _ = crate::txn::checkpoint::run(self)?;
tracing::error!(
collection = name,
discarded,
"dropped replay-ambiguous pending transactions before format migration"
);
Ok(())
}
fn remove_committed_format_marker(
&self,
name: &'static str,
stored: Option<&CollectionInfo>,
) -> DbResult<()> {
let Some(marker) = read_collection_marker(&self.tree_path(name))? else {
return Ok(());
};
if stored.is_some_and(|info| info.version == marker.to) {
remove_collection_marker(&self.tree_path(name))?;
}
Ok(())
}
fn commit_format_outcome(
&self,
name: &'static str,
typ_hash: u64,
plan: &MigrationPlan,
claims: bool,
outcome: &BackendMigrationOutcome,
) -> DbResult<()> {
#[cfg(any(test, feature = "test-faults"))]
crate::format_migration::failpoints::hit(
crate::format_migration::failpoints::BEFORE_INFO_COMMIT,
);
self.update_collection_info(name, |entry| {
entry.version = plan.format_to_version;
entry.typ_hash = typ_hash;
entry.gsn_high = entry.gsn_high.max(outcome.gsn_high);
if entry.shard_gsn_high.len() < outcome.shard_gsn_high.len() {
entry.shard_gsn_high.resize(outcome.shard_gsn_high.len(), 0);
}
for (index, high) in outcome.shard_gsn_high.iter().copied().enumerate() {
entry.shard_gsn_high[index] = entry.shard_gsn_high[index].max(high);
}
entry.projections_dirty = true;
entry.counters_dirty = true;
entry.unique_dirty |= claims;
})?;
#[cfg(any(test, feature = "test-faults"))]
crate::format_migration::failpoints::hit(
crate::format_migration::failpoints::AFTER_INFO_COMMIT,
);
let committed = self.stored_info(name).ok_or_else(|| {
DbError::FormatMismatch(format!("missing committed format metadata for `{name}`"))
})?;
if committed.version != plan.format_to_version || committed.typ_hash != typ_hash {
return Err(DbError::FormatMismatch(format!(
"format metadata readback for `{name}` did not reach version {}",
plan.format_to_version
)));
}
#[cfg(any(test, feature = "test-faults"))]
crate::format_migration::failpoints::hit(
crate::format_migration::failpoints::BEFORE_MARKER_REMOVE,
);
remove_collection_marker(&self.tree_path(name))
}
fn run_migration<K, T>(
&self,
meta: &TreeMeta,
stored: Option<&CollectionInfo>,
migrations: &[TypedMigration<K, T>],
mut migrate_fn: impl FnMut(&super::migration::TypedMigrationFn<K, T>) -> DbResult<usize>,
flush_fn: impl Fn() -> DbResult<()>,
) -> DbResult<bool> {
let Some(stored) = stored else {
return Ok(false);
};
if stored.version == meta.version && stored.typ_hash != meta.ty.h() {
if stored.typ_hash == TYP_HASH_MIGRATING {
tracing::warn!(
name = meta.name,
version = stored.version,
"typ_hash is the migration sentinel — skipping V-005 (interrupted multi-step migration)"
);
} else {
return Err(DbError::SchemaMismatch {
name: meta.name.into(),
kind: SchemaMismatchKind::TypHash {
stored: stored.typ_hash,
expected: meta.ty.h(),
},
});
}
}
if stored.version > meta.version {
return Err(DbError::SchemaMismatch {
name: meta.name.into(),
kind: SchemaMismatchKind::Downgrade {
stored: stored.version,
requested: meta.version,
},
});
}
if stored.version == meta.version {
return Ok(false);
}
let mut current = stored.version;
let mut mutated_any = false;
while current < meta.version {
let (_, mfn) = migrations
.iter()
.find(|(v, _)| *v == current)
.ok_or_else(|| DbError::SchemaMismatch {
name: meta.name.into(),
kind: SchemaMismatchKind::MissingStep { from: current },
})?;
let _stream_apply_guard = crate::txn_depth::StreamApplyGuard::enter();
let mutated = migrate_fn(mfn)?;
flush_fn()?;
let step_hash = if current + 1 == meta.version {
meta.ty.h()
} else {
TYP_HASH_MIGRATING
};
self.update_schema_version(meta.name, current + 1, step_hash)?;
tracing::info!(mutated, from = current, to = current + 1, "migration step");
current += 1;
mutated_any = true;
}
if mutated_any {
self.set_counters_dirty(meta.name, true)?;
}
Ok(mutated_any)
}
fn update_schema_version(&self, name: &str, version: u16, typ_hash: u64) -> DbResult<()> {
self.update_collection_info(name, |entry| {
entry.version = version;
entry.typ_hash = typ_hash;
})?;
Ok(())
}
fn save_info(
&self,
meta: &TreeMeta,
seq: u64,
projections_dirty: bool,
counters_dirty: bool,
) -> DbResult<()> {
let typ_hash = meta.ty.h();
self.update_collection_info(meta.name, |entry| {
entry.version = meta.version;
entry.typ_hash = typ_hash;
entry.seq = seq;
entry.projections_dirty = projections_dirty;
entry.counters_dirty = counters_dirty;
})?;
Ok(())
}
fn update_collection_info(
&self,
name: &str,
update: impl FnOnce(&mut CollectionInfo),
) -> DbResult<()> {
self.db_info
.update(|info| update(info.collections.entry(name.to_owned()).or_default()))?;
Ok(())
}
fn set_projections_dirty(&self, name: &str, dirty: bool) -> DbResult<()> {
self.db_info.update(|info| {
let entry = info.collections.entry(name.to_owned()).or_default();
entry.projections_dirty = dirty;
})?;
Ok(())
}
fn clear_consumed_counter_flags(&self, collections: &[Arc<dyn Collection>]) -> DbResult<()> {
let mut first_err: Option<DbError> = None;
let consumed = std::mem::take(&mut *lock(&self.counter_targets_consumed));
for c in collections.iter() {
if !c.reset_counter_dirty_marker() {
continue;
}
if let Err(e) = self.clear_counters_dirty(c.name()) {
tracing::warn!(collection = c.name(), error = %e, "clear counters_dirty failed");
first_err.get_or_insert(e);
}
}
for name in consumed {
if let Err(e) = self.clear_counters_dirty(name) {
tracing::warn!(collection = name, error = %e, "clear consumed target counters_dirty failed");
first_err.get_or_insert(e);
}
}
first_err.map_or(Ok(()), Err)
}
fn clear_counters_dirty(&self, name: &str) -> DbResult<()> {
self.db_info.update(|info| {
let entry = info.collections.entry(name.to_owned()).or_default();
entry.counters_dirty = false;
})?;
Ok(())
}
fn set_counters_dirty(&self, name: &str, dirty: bool) -> DbResult<()> {
self.db_info.update(|info| {
let entry = info.collections.entry(name.to_owned()).or_default();
entry.counters_dirty = dirty;
})?;
Ok(())
}
pub(crate) fn clear_unique_dirty(&self, names: &[&str]) -> DbResult<()> {
self.db_info.update(|info| {
for name in names {
let entry = info.collections.entry((*name).to_owned()).or_default();
entry.unique_dirty = false;
}
})?;
Ok(())
}
fn migration_will_run(stored: Option<&CollectionInfo>, meta: &TreeMeta) -> bool {
let Some(stored) = stored else {
return false;
};
stored.version < meta.version
}
fn save_collection_len(&self, name: &str, len: u64) -> DbResult<()> {
self.db_info.update(|info| {
if let Some(ci) = info.collections.get_mut(name) {
ci.seq = len;
}
})?;
Ok(())
}
pub fn inspector(&self) -> super::inspection::Inspector<'_> {
super::inspection::Inspector { db: self }
}
#[cfg(test)]
fn publish_collection(
&self,
name: &'static str,
collection: Arc<dyn Collection>,
inspection: Arc<dyn InspectionHandler>,
#[cfg(feature = "rpc")] rpc: Option<(u64, Arc<dyn super::handler::RpcHandler>)>,
) -> DbResult<()> {
let mut collections = lock(&self.collections);
let mut inspections = lock(&self.inspection_handlers);
#[cfg(feature = "rpc")]
let mut handlers = lock(&self.handlers);
if collections.iter().any(|c| c.name() == name) {
return Err(DbError::DuplicateCollection(name));
}
if inspections.contains_key(name) {
return Err(DbError::DuplicateCollection(name));
}
#[cfg(feature = "rpc")]
if let Some((hash, ref handler)) = rpc
&& let Some((_, existing)) = handlers.iter().find(|(k, _)| *k == hash)
{
tracing::error!(
collection = name,
conflicting = handler.name(),
existing = existing.name(),
hash,
"xxh3-64 collection name collision — refusing to register a second \
handler under the same hash"
);
return Err(DbError::DuplicateCollection(
"rpc handler hash collision (see log for the two collection names)",
));
}
collections.push(collection);
inspections.insert(name, inspection);
#[cfg(feature = "rpc")]
if let Some((hash, handler)) = rpc {
handlers.push((hash, handler));
}
Ok(())
}
#[cfg(test)]
pub(crate) fn publish_collection_for_test(
&self,
name: &'static str,
collection: Arc<dyn Collection>,
inspection: Arc<dyn InspectionHandler>,
#[cfg(feature = "rpc")] rpc: Option<Arc<dyn super::handler::RpcHandler>>,
forced_hash: Option<u64>,
) -> DbResult<()> {
#[cfg(feature = "rpc")]
let rpc = rpc.map(|handler| {
let hash = forced_hash.unwrap_or_else(|| xxhash_rust::xxh3::xxh3_64(name.as_bytes()));
(hash, handler)
});
#[cfg(not(feature = "rpc"))]
let _ = forced_hash;
self.publish_collection(
name,
collection,
inspection,
#[cfg(feature = "rpc")]
rpc,
)
}
fn stage_to_adapter<'a, S, W>(
&self,
staged: StagedManaged<'a, S, W>,
access: ManagedCollectionAccess,
) -> StagedAdapter<'a>
where
S: crate::access::Access + Collection + 'static,
W: crate::access::Wire<S::Val>,
{
let StagedManaged {
meta,
collection,
wire,
_name_guard,
} = staged;
let adapter = Arc::new(crate::access::Adapter::new(
&meta,
collection.clone(),
wire,
self.seq.clone(),
));
let inspection: Arc<dyn InspectionHandler> = adapter.clone();
let collection: Arc<dyn Collection> = collection;
#[cfg(not(feature = "rpc"))]
let _ = access;
#[cfg(feature = "rpc")]
let rpc = (access == ManagedCollectionAccess::Public).then(|| {
(
xxhash_rust::xxh3::xxh3_64(meta.name.as_bytes()),
adapter as Arc<dyn super::handler::RpcHandler>,
)
});
StagedAdapter {
name: meta.name,
collection,
inspection,
#[cfg(feature = "rpc")]
rpc,
_name_guard,
}
}
fn publish_staged_adapters(&self, staged: Vec<StagedAdapter<'_>>) -> DbResult<()> {
let mut collections = lock(&self.collections);
let mut inspections = lock(&self.inspection_handlers);
#[cfg(feature = "rpc")]
let mut handlers = lock(&self.handlers);
for adapter in &staged {
if collections.iter().any(|c| c.name() == adapter.name)
|| inspections.contains_key(adapter.name)
|| staged
.iter()
.filter(|other| other.name == adapter.name)
.count()
!= 1
{
return Err(DbError::DuplicateCollection(adapter.name));
}
#[cfg(feature = "rpc")]
if let Some((hash, ref handler)) = adapter.rpc
&& (handlers.iter().any(|(existing, _)| *existing == hash)
|| staged
.iter()
.filter(|other| {
other
.rpc
.as_ref()
.is_some_and(|(other_hash, _)| *other_hash == hash)
})
.count()
!= 1)
{
tracing::error!(
collection = adapter.name,
conflicting = handler.name(),
hash,
"RPC handler collision while publishing managed collection group"
);
return Err(DbError::DuplicateCollection(
"rpc handler hash collision (see log for the two collection names)",
));
}
}
let names: Vec<_> = staged.iter().map(|adapter| adapter.name).collect();
for adapter in staged {
collections.push(adapter.collection);
inspections.insert(adapter.name, adapter.inspection);
#[cfg(feature = "rpc")]
if let Some((hash, handler)) = adapter.rpc {
handlers.push((hash, handler));
}
}
#[cfg(feature = "rpc")]
drop(handlers);
drop(inspections);
drop(collections);
for name in names {
self.note_tx_replay_done(name);
}
Ok(())
}
}
pub struct DbBuilder {
path: std::path::PathBuf,
workers: WorkerIntervals,
sync_persist: bool,
tx_config: crate::txn::TxConfig,
}
impl DbBuilder {
#[must_use]
pub fn workers(mut self, workers: WorkerIntervals) -> Self {
self.workers = workers;
self
}
#[must_use]
pub fn tx_config(mut self, tx_config: crate::txn::TxConfig) -> Self {
self.tx_config = tx_config;
self
}
#[must_use]
pub fn for_tests(mut self) -> Self {
self.workers = WorkerIntervals {
compaction: None,
flush: None,
ttl_sweep: None,
ttl_budget: DEFAULT_TTL_BUDGET,
};
self.sync_persist = false;
self
}
#[doc = include_str!("../../docs/exclusive-access.md")]
#[doc = include_str!("../../docs/exclusive-access-root.md")]
pub fn open(self) -> DbResult<Db> {
Db::open_with_options(self.path, self.workers, self.sync_persist, self.tx_config)
}
}
#[cfg(feature = "rpc")]
impl Db {
pub fn rpc_handler_for(&self, name: &str) -> Option<Arc<dyn super::handler::RpcHandler>> {
let h = xxhash_rust::xxh3::xxh3_64(name.as_bytes());
lock(&self.handlers)
.iter()
.find(|(k, _)| *k == h)
.map(|(_, v)| v.clone())
}
pub fn build_tree_map(&self) -> super::rpc::TreeMap {
Arc::new(
lock(&self.handlers)
.iter()
.map(|(h, handler)| (*h, handler.clone()))
.collect(),
)
}
}
impl Drop for Db {
fn drop(&mut self) {
self.stop_workers();
if let Err(error) = self.close() {
tracing::error!(
error = %error,
"Db drop: close failed; durability of the last writes is not guaranteed"
);
}
self.close_all_watches();
}
}
#[cfg(all(test, feature = "rapira-codec"))]
mod tests {
use super::*;
use armour_core::GetType;
use rapira::Rapira;
use tempfile::tempdir;
use crate::armour::migration::TypedMigration;
use crate::{MigrateAction, NoHook, RapiraCodec};
#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct User {
id: u64,
name: String,
}
impl CollectionMeta for User {
type SelfId = [u8; 8];
const NAME: &'static str = "iterable_toggle_users";
const VERSION: u16 = 1;
}
#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct StagedFirst;
impl CollectionMeta for StagedFirst {
type SelfId = [u8; 8];
const NAME: &'static str = "staged_pair_first";
}
#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct StagedSecond;
impl CollectionMeta for StagedSecond {
type SelfId = [u8; 8];
const NAME: &'static str = "staged_pair_second";
}
fn key(id: u64) -> [u8; 8] {
id.to_be_bytes()
}
fn demo_user() -> User {
User {
id: 1,
name: "alice".into(),
}
}
#[test]
fn open_same_collection_twice_errors() {
let dir = tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let _first = db
.open_typed_map::<User, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("first open succeeds");
let err = db
.open_typed_map::<User, RapiraCodec, _>(Config::test(), NoHook, &[])
.err()
.expect("second open of the same name must fail");
assert!(
matches!(err, DbError::DuplicateCollection("iterable_toggle_users")),
"got {err:?}"
);
}
#[test]
fn failed_after_stage_callback_leaves_pair_unpublished_and_retryable() {
let dir = tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let result = db.open_managed_pair(
|meta| {
TypedMap::<[u8; 8], StagedFirst, RapiraCodec, TtlHook<StagedFirst, NoHook>>::open_inner(
db.tree_path(meta.name), Config::test(), RapiraCodec, TtlHook::new(NoHook),
)
},
crate::access::CodecWire(RapiraCodec),
&[],
StorageDescriptor::Bitcask {
config: &Config::test(),
key_len: 8,
claims: false,
},
|meta| {
TypedMap::<[u8; 8], StagedSecond, RapiraCodec, TtlHook<StagedSecond, NoHook>>::open_inner(
db.tree_path(meta.name), Config::test(), RapiraCodec, TtlHook::new(NoHook),
)
},
crate::access::CodecWire(RapiraCodec),
&[],
StorageDescriptor::Bitcask {
config: &Config::test(),
key_len: 8,
claims: false,
},
|_, _| Err(DbError::Client("staged cleanup failed")),
ManagedCollectionAccess::Public,
ManagedCollectionAccess::Public,
);
assert!(matches!(
result,
Err(DbError::Client("staged cleanup failed"))
));
assert!(lock(&db.collections).is_empty());
db.open_typed_map::<StagedFirst, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
db.open_typed_map::<StagedSecond, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
}
#[cfg(feature = "var-collections")]
mod format_pair_descriptor_tests {
use super::*;
const FIXED_NAME: &str = "format_pair_fixed_header";
const PAYLOAD_NAME: &str = "format_pair_bitcask_payload";
#[derive(
Clone,
Copy,
Debug,
Eq,
PartialEq,
zerocopy::FromBytes,
zerocopy::IntoBytes,
zerocopy::Immutable,
)]
#[repr(C)]
struct FixedHeaderV1 {
value: u32,
}
impl GetType for FixedHeaderV1 {
const TYPE: armour_core::Typ = <u32 as GetType>::TYPE;
}
impl CollectionMeta for FixedHeaderV1 {
type SelfId = [u8; 8];
const NAME: &'static str = FIXED_NAME;
const VERSION: u16 = 1;
}
#[derive(
Clone,
Copy,
Debug,
Eq,
PartialEq,
zerocopy::FromBytes,
zerocopy::IntoBytes,
zerocopy::Immutable,
)]
#[repr(C)]
struct FixedHeaderV2 {
value: u64,
}
impl GetType for FixedHeaderV2 {
const TYPE: armour_core::Typ = <u64 as GetType>::TYPE;
}
fn widen_header(
_key: &[u8],
old: &[u8],
out: &mut Vec<u8>,
) -> DbResult<crate::FormatAction> {
let value = u32::from_ne_bytes(old.try_into().expect("v1 header width"));
out.extend_from_slice(&u64::from(value).to_ne_bytes());
Ok(crate::FormatAction::Write)
}
const HEADER_FORMAT: crate::FormatMigration = crate::FormatMigration::raw_checked(
1,
&FixedHeaderV1::TYPE,
&FixedHeaderV2::TYPE,
widen_header,
);
impl CollectionMeta for FixedHeaderV2 {
type SelfId = [u8; 8];
const NAME: &'static str = FIXED_NAME;
const VERSION: u16 = 2;
const FORMAT_MIGRATIONS: &'static [crate::FormatMigration] = &[HEADER_FORMAT];
}
#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct PayloadV1 {
bytes: Vec<u8>,
}
impl CollectionMeta for PayloadV1 {
type SelfId = [u8; 8];
const NAME: &'static str = PAYLOAD_NAME;
const VERSION: u16 = 1;
}
#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct PayloadV2 {
bytes: Vec<u8>,
generation: u16,
}
const PAYLOAD_FORMAT: crate::FormatMigration = crate::format_step!(
1,
PayloadV1 => PayloadV2,
RapiraCodec,
|old| Ok::<_, DbError>(Some(PayloadV2 {
bytes: old.bytes,
generation: 2,
}))
);
impl CollectionMeta for PayloadV2 {
type SelfId = [u8; 8];
const NAME: &'static str = PAYLOAD_NAME;
const VERSION: u16 = 2;
const FORMAT_MIGRATIONS: &'static [crate::FormatMigration] = &[PAYLOAD_FORMAT];
}
#[test]
fn format_migration_paired_fixed_header_and_bitcask_payload_routes_descriptors() {
let dir = tempdir().expect("tempdir");
let key = 21u64.to_be_bytes();
let payload_bytes = vec![0x3c; 16 * 1024 + 9];
let db = Db::open_test(dir.path()).expect("open v1 db");
let header = db
.open_zero_tree_fixed::<FixedHeaderV1, { std::mem::size_of::<FixedHeaderV1>() }, _>(
crate::FixedConfig::test(),
NoHook,
&[],
)
.expect("open v1 fixed header");
let payload = db
.open_var_typed_tree::<PayloadV1, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open v1 bitcask payload");
header
.put(&key, &FixedHeaderV1 { value: 21 })
.expect("seed fixed header");
payload
.put(
&key,
&PayloadV1 {
bytes: payload_bytes.clone(),
},
)
.expect("seed bitcask payload");
drop(header);
drop(payload);
db.close().expect("close v1 db");
drop(db);
let db = Db::open_test(dir.path()).expect("reopen v2 db");
let fixed_config = crate::FixedConfig::test();
let fixed_descriptor_config = fixed_config.clone();
let payload_config = Config::test();
let payload_descriptor_config = payload_config.clone();
let (header, payload) = db
.open_managed_pair(
|meta| {
crate::ZeroTree::<
[u8; 8],
{ std::mem::size_of::<FixedHeaderV2>() },
FixedHeaderV2,
TtlHook<FixedHeaderV2, NoHook>,
crate::durability::Fixed,
>::open_with_hook_inner(
db.tree_path(meta.name),
fixed_config,
TtlHook::new(NoHook),
)
},
crate::access::ZeroWire::<{ std::mem::size_of::<FixedHeaderV2>() }>,
&[],
StorageDescriptor::Fixed {
config: &fixed_descriptor_config,
key_len: std::mem::size_of::<[u8; 8]>(),
value_len: std::mem::size_of::<FixedHeaderV2>() as u16,
claims: false,
},
|meta| {
crate::VarTypedTree::<
[u8; 8],
PayloadV2,
RapiraCodec,
TtlHook<PayloadV2, NoHook>,
>::open_hooked_inner(
db.tree_path(meta.name),
payload_config,
RapiraCodec,
TtlHook::new(NoHook),
)
},
crate::access::CodecWire(RapiraCodec),
&[],
StorageDescriptor::Bitcask {
config: &payload_descriptor_config,
key_len: std::mem::size_of::<[u8; 8]>(),
claims: false,
},
|_, _| Ok(()),
ManagedCollectionAccess::Internal,
ManagedCollectionAccess::Internal,
)
.expect("migrate paired managed collections");
assert_eq!(header.get(&key), Some(FixedHeaderV2 { value: 21 }));
assert_eq!(
payload.get(&key),
Some(PayloadV2 {
bytes: payload_bytes,
generation: 2,
})
);
}
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
zerocopy::FromBytes,
zerocopy::IntoBytes,
zerocopy::Immutable,
zerocopy::KnownLayout,
)]
#[repr(C)]
struct OverCapKey([u8; crate::unique::UNIQUE_OWNER_KEY_CAP + 8]);
crate::impl_key_zerocopy!(
OverCapKey,
armour_core::KeyScheme::Typed(&[armour_core::KeyType::Array(
crate::unique::UNIQUE_OWNER_KEY_CAP + 8
)])
);
#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct WideKeyUser {
name: String,
}
impl CollectionMeta for WideKeyUser {
type SelfId = OverCapKey;
const NAME: &'static str = "f3_wide_key_users";
}
#[test]
fn open_managed_rejects_a_hand_assembled_carrier_whose_key_is_too_wide() {
let (_dir, db, ns) = crate::unique_tests::open_claim_ns();
let hook = crate::projection::Projected::new(crate::IndexTargets::new(), |_, _, _| Ok(()))
.and_unique(
crate::UniqueTargets::new().with(&ns),
move |_k, _v, sink| sink.claim(&ns, crate::unique_tests::ClaimFwdKey::new(1)),
);
let result = db.open_typed_map::<WideKeyUser, RapiraCodec, _>(Config::test(), hook, &[]);
let Err(err) = result else {
panic!("a claim carrier whose source key exceeds the owner capacity must be rejected");
};
assert!(
matches!(
err,
DbError::ClaimingSourceKeyTooWide {
collection: "f3_wide_key_users"
}
),
"expected ClaimingSourceKeyTooWide, got {err:?}"
);
}
#[test]
fn claim_owner_tag_collision_between_two_sources_is_rejected() {
let (_dir, db, ns) = crate::unique_tests::open_claim_ns();
let targets = crate::UniqueTargets::new().with(&ns);
db.register_claim_owner_tag(42, "src_a", &targets)
.expect("first source registers cleanly");
db.register_claim_owner_tag(42, "src_a", &targets)
.expect("re-registering the same source is not a collision");
let err = db
.register_claim_owner_tag(42, "src_b", &targets)
.expect_err("a different source under the same tag must collide");
assert!(
matches!(
err,
DbError::ClaimOwnerTagCollision {
namespace: "claim_forwarding_test_ns",
a: "src_a",
b: "src_b",
}
),
"expected ClaimOwnerTagCollision, got {err:?}"
);
}
#[test]
fn concurrent_open_same_name_exactly_one_wins() {
use std::sync::{Arc, Barrier};
let dir = tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
const N: usize = 8;
let barrier = Arc::new(Barrier::new(N));
let handles: Vec<_> = (0..N)
.map(|_| {
let db = Arc::clone(&db);
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
db.open_typed_map::<User, RapiraCodec, _>(Config::test(), NoHook, &[])
.is_ok()
})
})
.collect();
let wins = handles
.into_iter()
.map(|h| h.join().unwrap())
.filter(|&ok| ok)
.count();
assert_eq!(
wins, 1,
"expected exactly one concurrent open to win, got {wins}"
);
}
#[test]
fn open_reserved_name_errors() {
#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct SeqNamed {
x: u64,
}
impl CollectionMeta for SeqNamed {
type SelfId = [u8; 8];
const NAME: &'static str = "__seq";
const VERSION: u16 = 1;
}
let dir = tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let err = db
.open_typed_map::<SeqNamed, RapiraCodec, _>(Config::test(), NoHook, &[])
.err()
.expect("reserved name must be rejected");
assert!(
matches!(err, DbError::DuplicateCollection("__seq")),
"got {err:?}"
);
}
#[test]
fn set_metadata_serialize_failure_returns_err_not_panic() {
use serde::de::{Deserialize, Deserializer};
use serde::ser::{Error as _, Serialize, Serializer};
#[derive(Debug)]
struct FailSer;
impl Serialize for FailSer {
fn serialize<S: Serializer>(&self, _s: S) -> std::result::Result<S::Ok, S::Error> {
Err(S::Error::custom("intentional serialize failure"))
}
}
impl<'de> Deserialize<'de> for FailSer {
fn deserialize<D: Deserializer<'de>>(_d: D) -> std::result::Result<Self, D::Error> {
Ok(FailSer)
}
}
let dir = tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let res = db.set_metadata::<FailSer>(|_current| FailSer);
assert!(
matches!(res, Err(DbError::FormatMismatch(_))),
"expected FormatMismatch, got {res:?}"
);
}
#[test]
fn db_map_iterable_toggles_across_reopen() {
let dir = tempdir().unwrap();
{
let db = Db::open_test(dir.path()).unwrap();
let users = db
.open_typed_map::<User, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
assert!(users.iter_view().is_none());
users.put(&key(1), demo_user()).unwrap();
db.close().unwrap();
}
let db = Db::open_test(dir.path()).unwrap();
let cfg = Config::balanced()
.shard_count(2)
.hints(true)
.iterable(true)
.build();
let users = db
.open_typed_map::<User, RapiraCodec, _>(cfg, NoHook, &[])
.unwrap();
assert!(users.iter_view().is_some());
assert_eq!(users.iter_view().unwrap().iter().count(), 1);
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
GetType,
zerocopy::FromBytes,
zerocopy::IntoBytes,
zerocopy::Immutable,
)]
#[repr(C)]
struct FxCounter {
n: u32,
}
impl CollectionMeta for FxCounter {
type SelfId = [u8; 8];
const NAME: &'static str = "fixed_reversed_db";
const VERSION: u16 = 1;
}
impl CollectionMeta for FxCounterMap {
type SelfId = [u8; 8];
const NAME: &'static str = "fixed_reversed_db_map";
const VERSION: u16 = 1;
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
GetType,
zerocopy::FromBytes,
zerocopy::IntoBytes,
zerocopy::Immutable,
)]
#[repr(C)]
struct FxCounterMap {
n: u32,
}
#[test]
fn db_fixed_openers_honor_reversed() {
let dir = tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let tree = db
.open_zero_tree_fixed::<FxCounter, 4, _>(FixedConfig::test(), NoHook, &[])
.unwrap();
for i in 1u64..=3 {
tree.put(&i.to_be_bytes(), &FxCounter { n: i as u32 })
.unwrap();
}
let tk: Vec<u64> = tree.iter().map(|(k, _)| u64::from_be_bytes(k)).collect();
assert_eq!(tk, [3, 2, 1], "Db fixed tree default DESC");
let map_cfg = FixedConfig {
iterable: true,
reversed: false,
..FixedConfig::test()
};
let map = db
.open_zero_map_fixed::<FxCounterMap, 4, _>(map_cfg, NoHook, &[])
.unwrap();
for i in 1u64..=3 {
map.put(&i.to_be_bytes(), &FxCounterMap { n: i as u32 })
.unwrap();
}
let mk: Vec<u64> = map
.iter_view()
.unwrap()
.keys()
.map(u64::from_be_bytes)
.collect();
assert_eq!(mk, [1, 2, 3], "Db fixed map reversed=false ASC");
}
#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct FlushMigItem {
value: i64,
}
impl CollectionMeta for FlushMigItem {
type SelfId = [u8; 8];
const NAME: &'static str = "flush_mig_items";
const VERSION: u16 = 2;
}
fn keep_flush(_k: &[u8; 8], _v: &FlushMigItem) -> MigrateAction<FlushMigItem> {
MigrateAction::Keep
}
#[test]
fn migration_flush_fn_reaches_engine_durable_flush() {
use std::sync::atomic::Ordering;
let dir = tempdir().unwrap();
{
let db = Db::open_test(dir.path()).unwrap();
let tree = db
.open_typed_tree::<FlushMigItem, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
tree.put(&1u64.to_be_bytes(), FlushMigItem { value: 7 })
.unwrap();
db.db_info
.update(|info| {
info.collections
.get_mut(FlushMigItem::NAME)
.unwrap()
.version = 1;
})
.unwrap();
}
let before = crate::engine::ENGINE_DURABLE_FLUSHES.load(Ordering::Relaxed);
{
let db = Db::open_test(dir.path()).unwrap();
let migrations: &[TypedMigration<[u8; 8], FlushMigItem>] = &[(1u16, keep_flush)];
let _tree = db
.open_typed_tree::<FlushMigItem, RapiraCodec, _>(Config::test(), NoHook, migrations)
.unwrap();
}
let after = crate::engine::ENGINE_DURABLE_FLUSHES.load(Ordering::Relaxed);
assert!(
after > before,
"migration flush_fn must hit Engine::flush (fsync)"
);
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
GetType,
zerocopy::FromBytes,
zerocopy::IntoBytes,
zerocopy::Immutable,
)]
#[repr(C)]
struct FlushMigZero {
n: u32,
}
impl CollectionMeta for FlushMigZero {
type SelfId = [u8; 8];
const NAME: &'static str = "flush_mig_zero";
const VERSION: u16 = 2;
}
fn keep_flush_zero(_k: &[u8; 8], _v: &FlushMigZero) -> MigrateAction<FlushMigZero> {
MigrateAction::Keep
}
#[test]
fn zero_opener_migration_flush_fn_reaches_engine_durable_flush() {
use std::sync::atomic::Ordering;
const V: usize = std::mem::size_of::<FlushMigZero>();
let dir = tempdir().unwrap();
{
let db = Db::open_test(dir.path()).unwrap();
let tree = db
.open_zero_tree::<FlushMigZero, V, _>(Config::test(), NoHook, &[])
.unwrap();
tree.put(&1u64.to_be_bytes(), &FlushMigZero { n: 7 })
.unwrap();
db.db_info
.update(|info| {
info.collections
.get_mut(FlushMigZero::NAME)
.unwrap()
.version = 1;
})
.unwrap();
}
let before = crate::engine::ENGINE_DURABLE_FLUSHES.load(Ordering::Relaxed);
{
let db = Db::open_test(dir.path()).unwrap();
let migrations: &[TypedMigration<[u8; 8], FlushMigZero>] = &[(1u16, keep_flush_zero)];
let _tree = db
.open_zero_tree::<FlushMigZero, V, _>(Config::test(), NoHook, migrations)
.unwrap();
}
let after = crate::engine::ENGINE_DURABLE_FLUSHES.load(Ordering::Relaxed);
assert!(
after > before,
"zero opener flush_fn must resolve to the durable inherent ZeroTree::flush (Engine::flush fsync)"
);
}
#[test]
fn run_migration_final_step_writes_real_typ_hash() {
#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct TwoStep {
value: i64,
}
impl CollectionMeta for TwoStep {
type SelfId = [u8; 8];
const NAME: &'static str = "run_mig_two_step";
const VERSION: u16 = 3;
}
fn keep(_k: &[u8; 8], _v: &TwoStep) -> MigrateAction<TwoStep> {
MigrateAction::Keep
}
let dir = tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let meta = TreeMeta::of::<TwoStep>();
let stored = CollectionInfo {
version: 1,
typ_hash: meta.ty.h(),
seq: 0,
projections_dirty: false,
counters_dirty: false,
..CollectionInfo::default()
};
let migrations: &[TypedMigration<[u8; 8], TwoStep>] = &[(1, keep), (2, keep)];
let mut seen = Vec::new();
let mutated = db
.run_migration(
&meta,
Some(&stored),
migrations,
|_mfn| {
if let Some(ci) = db.db_info.cloned().collections.get(TwoStep::NAME) {
seen.push(ci.typ_hash);
}
Ok(1)
},
|| Ok(()),
)
.unwrap();
assert!(mutated);
assert_eq!(seen, vec![TYP_HASH_MIGRATING]);
let info = db.db_info.cloned();
let ci = info.collections.get(TwoStep::NAME).unwrap();
assert_eq!(ci.version, 3);
assert_eq!(
ci.typ_hash,
meta.ty.h(),
"финальный шаг обязан записать реальный typ_hash, не sentinel"
);
assert_ne!(ci.typ_hash, TYP_HASH_MIGRATING);
}
#[test]
fn db_open_twice_same_path_errors() {
let dir = tempdir().unwrap();
let _first = Db::open_test(dir.path()).expect("first open");
let err = Db::open_test(dir.path())
.err()
.expect("second open of a live root must fail");
assert!(matches!(err, DbError::Locked { .. }), "got {err:?}");
}
#[test]
fn db_lock_released_on_drop() {
let dir = tempdir().unwrap();
let first = Db::open_test(dir.path()).expect("first open");
drop(first);
Db::open_test(dir.path()).expect("reopen after drop must succeed");
}
#[test]
fn db_lock_released_after_background_workers() {
let dir = tempdir().unwrap();
let db = Db::open_with_intervals(
dir.path(),
Some(std::time::Duration::from_millis(10)),
Some(std::time::Duration::from_millis(10)),
)
.expect("open with background workers");
drop(db);
Db::open_test(dir.path()).expect("reopen immediately after workers stop");
}
#[test]
fn db_lock_collection_name_reserved() {
let dir = tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let err = db
.reserve_name("db.lock")
.err()
.expect("db.lock must be reserved: tree_path would alias the root lock file");
assert!(
matches!(err, DbError::DuplicateCollection("db.lock")),
"got {err:?}"
);
}
}
#[cfg(test)]
mod clean_shutdown_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct MockCollection {
name: &'static str,
fail: bool,
calls: Arc<AtomicUsize>,
}
impl Collection for MockCollection {
fn name(&self) -> &str {
self.name
}
fn len(&self) -> usize {
0
}
fn compact(&self) -> DbResult<usize> {
Ok(0)
}
fn flush(&self) -> DbResult<()> {
Ok(())
}
fn periodic_flush(&self) -> DbResult<()> {
Ok(())
}
fn durable_flush(&self) -> DbResult<()> {
Ok(())
}
fn clean_shutdown(&self) -> DbResult<()> {
self.calls.fetch_add(1, Ordering::SeqCst);
if self.fail {
Err(DbError::Io(std::io::Error::other(
"mock clean_shutdown failure",
)))
} else {
Ok(())
}
}
}
#[test]
fn test_db_clean_shutdown_attempt_all() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let calls = Arc::new(AtomicUsize::new(0));
let calls2 = Arc::clone(&calls);
lock(&db.collections).push(Arc::new(MockCollection {
name: "fail_first",
fail: true,
calls,
}));
lock(&db.collections).push(Arc::new(MockCollection {
name: "ok_second",
fail: false,
calls: calls2.clone(),
}));
let err = db.clean_shutdown().unwrap_err();
assert!(
matches!(err, DbError::Io(_)),
"expected first collection error, got {err:?}"
);
assert_eq!(
calls2.load(Ordering::SeqCst),
2,
"both collections must be attempted"
);
}
}
#[cfg(test)]
mod close_tests {
use super::*;
#[cfg(feature = "fjall")]
use crate::RapiraCodec;
use armour_core::GetType;
#[cfg(feature = "fjall")]
use rapira::Rapira;
use std::sync::atomic::{AtomicUsize, Ordering};
struct FlushMock {
name: &'static str,
flush_fail: bool,
flush_calls: Arc<AtomicUsize>,
durable_calls: Arc<AtomicUsize>,
}
impl Collection for FlushMock {
fn name(&self) -> &str {
self.name
}
fn len(&self) -> usize {
0
}
fn compact(&self) -> DbResult<usize> {
Ok(0)
}
fn flush(&self) -> DbResult<()> {
self.flush_calls.fetch_add(1, Ordering::SeqCst);
if self.flush_fail {
Err(DbError::Io(std::io::Error::other("mock flush failure")))
} else {
Ok(())
}
}
fn periodic_flush(&self) -> DbResult<()> {
Ok(())
}
fn durable_flush(&self) -> DbResult<()> {
self.durable_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn clean_shutdown(&self) -> DbResult<()> {
Ok(())
}
}
#[test]
fn test_db_close_flushes_all_despite_first_failure() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let calls = Arc::new(AtomicUsize::new(0));
lock(&db.collections).push(Arc::new(FlushMock {
name: "fail_first",
flush_fail: true,
flush_calls: calls.clone(),
durable_calls: Arc::new(AtomicUsize::new(0)),
}));
lock(&db.collections).push(Arc::new(FlushMock {
name: "ok_second",
flush_fail: false,
flush_calls: calls.clone(),
durable_calls: Arc::new(AtomicUsize::new(0)),
}));
let err = db.close().unwrap_err();
assert!(
matches!(err, DbError::Io(_)),
"expected the first flush error to propagate, got {err:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"both collections must be flushed even though the first failed"
);
}
#[test]
fn close_is_barrier_after_a_flush_error() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let flush_calls = Arc::new(AtomicUsize::new(0));
let durable_calls = Arc::new(AtomicUsize::new(0));
lock(&db.collections).push(Arc::new(FlushMock {
name: "flush_fails_but_barriers",
flush_fail: true,
flush_calls,
durable_calls: Arc::clone(&durable_calls),
}));
assert!(db.close().is_err(), "the flush error remains the result");
assert_eq!(
durable_calls.load(Ordering::SeqCst),
1,
"close must still attempt the durable barrier after the hint/flush failure"
);
}
struct LengthFailureMock {
name: &'static str,
len: usize,
}
impl Collection for LengthFailureMock {
fn name(&self) -> &str {
self.name
}
fn len(&self) -> usize {
self.len
}
fn compact(&self) -> DbResult<usize> {
Ok(0)
}
fn flush(&self) -> DbResult<()> {
Err(DbError::Internal("injected close failure"))
}
fn periodic_flush(&self) -> DbResult<()> {
Ok(())
}
fn durable_flush(&self) -> DbResult<()> {
Ok(())
}
fn clean_shutdown(&self) -> DbResult<()> {
Err(DbError::Internal("injected clean shutdown failure"))
}
}
fn assert_length_persists_after_collection_failure(mode: FinalizeMode) {
const NAME: &str = "length_persists_after_failure";
const OLD_LEN: u64 = 3;
const NEW_LEN: u64 = 7;
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
db.db_info_update(|info| {
info.collections.entry(NAME.to_owned()).or_default().seq = OLD_LEN;
})
.unwrap();
lock(&db.collections).push(Arc::new(LengthFailureMock {
name: NAME,
len: NEW_LEN as usize,
}));
let result = match mode {
FinalizeMode::Close => db.close(),
FinalizeMode::CleanShutdown => db.clean_shutdown(),
};
assert!(result.is_err());
assert_eq!(
db.db_info().collections[NAME].seq,
NEW_LEN,
"length persistence must run after a collection finalization failure"
);
}
#[test]
fn close_persists_length_after_collection_failure() {
assert_length_persists_after_collection_failure(FinalizeMode::Close);
}
#[test]
fn clean_shutdown_persists_length_after_collection_failure() {
assert_length_persists_after_collection_failure(FinalizeMode::CleanShutdown);
}
#[derive(
Clone, Copy, GetType, zerocopy::FromBytes, zerocopy::Immutable, zerocopy::IntoBytes,
)]
#[repr(C)]
struct BarrierRow {
n: u64,
}
impl CollectionMeta for BarrierRow {
type SelfId = [u8; 8];
const NAME: &'static str = "close_barrier_rows";
}
fn single_shard_no_hints() -> Config {
let mut config = Config::test_no_hints();
config.shard_count = 1;
config
}
#[test]
fn close_is_barrier_for_dirty_no_hints_and_second_close_skips_clean_shards() {
crate::flush_test::reset();
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let tree = db
.open_zero_tree::<BarrierRow, 8, _>(single_shard_no_hints(), NoHook, &[])
.unwrap();
tree.put(&1u64.to_be_bytes(), &BarrierRow { n: 1 }).unwrap();
db.close().unwrap();
let after_first_close = crate::flush_test::data_attempts();
assert_eq!(
after_first_close, 1,
"close must barrier a dirty shard even when no checkpoint record exists"
);
db.close().unwrap();
assert_eq!(
crate::flush_test::data_attempts(),
after_first_close,
"a second close must skip a clean no-hints shard"
);
}
#[test]
fn close_hint_failure_still_durable_flushes_and_preserves_that_error() {
crate::flush_test::reset();
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let mut config = Config::test();
config.shard_count = 1;
config.hints = Some(true);
let tree = db
.open_zero_tree::<BarrierRow, 8, _>(config, NoHook, &[])
.unwrap();
tree.put(&1u64.to_be_bytes(), &BarrierRow { n: 1 }).unwrap();
let hint_path = db
.tree_path(BarrierRow::NAME)
.join("shard_000")
.join("000001.hint");
let _fault = crate::hint::fail_hint_write_for(
hint_path,
std::io::Error::other("test: close hint failure"),
);
let error = db.close().unwrap_err();
assert!(
error.to_string().contains("test: close hint failure"),
"the hint failure must retain first-error precedence: {error}"
);
assert_eq!(
crate::flush_test::data_attempts(),
1,
"close must still durable-flush after a failed hint write"
);
}
#[test]
fn drop_barriers_dirty_bitcask_and_skips_clean_bitcask() {
crate::flush_test::reset();
let dirty_dir = tempfile::tempdir().unwrap();
let dirty_db = Db::open_test(dirty_dir.path()).unwrap();
let dirty_tree = dirty_db
.open_zero_tree::<BarrierRow, 8, _>(single_shard_no_hints(), NoHook, &[])
.unwrap();
dirty_tree
.put(&1u64.to_be_bytes(), &BarrierRow { n: 1 })
.unwrap();
drop(dirty_tree);
drop(dirty_db);
assert_eq!(
crate::flush_test::data_attempts(),
1,
"Drop must barrier a dirty Bitcask shard"
);
crate::flush_test::reset();
let clean_dir = tempfile::tempdir().unwrap();
let clean_db = Db::open_test(clean_dir.path()).unwrap();
let clean_tree = clean_db
.open_zero_tree::<BarrierRow, 8, _>(single_shard_no_hints(), NoHook, &[])
.unwrap();
clean_tree
.put(&1u64.to_be_bytes(), &BarrierRow { n: 1 })
.unwrap();
clean_db.close().unwrap();
drop(clean_tree);
drop(clean_db);
assert_eq!(
crate::flush_test::data_attempts(),
1,
"Drop must skip an already-clean Bitcask shard"
);
}
struct WorkerShutdownMock {
clean_calls: Arc<AtomicUsize>,
watch_closes: Arc<AtomicUsize>,
}
impl Collection for WorkerShutdownMock {
fn name(&self) -> &str {
"worker_shutdown_mock"
}
fn len(&self) -> usize {
0
}
fn compact(&self) -> DbResult<usize> {
Ok(0)
}
fn flush(&self) -> DbResult<()> {
Ok(())
}
fn periodic_flush(&self) -> DbResult<()> {
Ok(())
}
fn durable_flush(&self) -> DbResult<()> {
Ok(())
}
fn clean_shutdown(&self) -> DbResult<()> {
self.clean_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn close_watches(&self) {
self.watch_closes.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn shutdown_is_idempotent_with_workers_and_drop_closes_watches() {
let dir = tempfile::tempdir().unwrap();
let mut db = Db::open_with_intervals(
dir.path(),
Some(std::time::Duration::from_millis(10)),
Some(std::time::Duration::from_millis(10)),
)
.unwrap();
let clean_calls = Arc::new(AtomicUsize::new(0));
let watch_closes = Arc::new(AtomicUsize::new(0));
lock(&db.collections).push(Arc::new(WorkerShutdownMock {
clean_calls: Arc::clone(&clean_calls),
watch_closes: Arc::clone(&watch_closes),
}));
db.shutdown().unwrap();
db.shutdown().unwrap();
drop(db);
assert_eq!(clean_calls.load(Ordering::SeqCst), 2);
assert_eq!(
watch_closes.load(Ordering::SeqCst),
3,
"both shutdown calls and Drop must terminally close watches"
);
}
#[cfg(feature = "fjall")]
#[derive(Clone, Debug, PartialEq, GetType, Rapira)]
struct LsmRow {
n: u64,
}
#[cfg(feature = "fjall")]
impl CollectionMeta for LsmRow {
type SelfId = [u8; 8];
const NAME: &'static str = "finalize_lsm_rows";
}
#[cfg(feature = "fjall")]
struct FailingLsmLengthProbe;
#[cfg(feature = "fjall")]
impl Collection for FailingLsmLengthProbe {
fn name(&self) -> &str {
LsmRow::NAME
}
fn len(&self) -> usize {
1
}
fn compact(&self) -> DbResult<usize> {
Ok(0)
}
fn flush(&self) -> DbResult<()> {
Err(DbError::Internal("injected LSM barrier failure"))
}
fn periodic_flush(&self) -> DbResult<()> {
Ok(())
}
fn durable_flush(&self) -> DbResult<()> {
Ok(())
}
fn clean_shutdown(&self) -> DbResult<()> {
Ok(())
}
}
#[cfg(feature = "fjall")]
#[test]
fn fjall_finalization_error_keeps_clean_false_and_reopens_from_live_length() {
let dir = tempfile::tempdir().unwrap();
{
let db = Db::open_test(dir.path()).unwrap();
let rows = db
.open_lsm_tree::<LsmRow, RapiraCodec, NoHook>(
Some(crate::lsm::LsmDbConfig::default()),
crate::lsm::LsmConfig::default(),
NoHook,
&[],
)
.unwrap();
for n in 1u64..=3 {
rows.put(&n.to_be_bytes(), &LsmRow { n }).unwrap();
}
lock(&db.collections).push(Arc::new(FailingLsmLengthProbe));
assert!(db.close().is_err());
let info = db.db_info();
assert!(!info.clean, "a failed finalization cannot publish clean");
assert_eq!(info.collections[LsmRow::NAME].seq, 1);
drop(rows);
}
let db = Db::open_test(dir.path()).unwrap();
assert!(
!db.opened_clean,
"reopen must not trust stale db.info length"
);
let rows = db
.open_lsm_tree::<LsmRow, RapiraCodec, NoHook>(
None,
crate::lsm::LsmConfig::default(),
NoHook,
&[],
)
.unwrap();
assert_eq!(rows.len(), 3, "unclean reopen must rescan the LSM keyspace");
}
#[cfg(feature = "fjall")]
#[test]
fn fjall_write_after_close_demotes_clean_before_a_failed_second_finalization() {
let dir = tempfile::tempdir().unwrap();
{
let db = Db::open_test(dir.path()).unwrap();
let rows = db
.open_lsm_tree::<LsmRow, RapiraCodec, NoHook>(
Some(crate::lsm::LsmDbConfig::default()),
crate::lsm::LsmConfig::default(),
NoHook,
&[],
)
.unwrap();
rows.put(&1u64.to_be_bytes(), &LsmRow { n: 1 }).unwrap();
rows.put(&2u64.to_be_bytes(), &LsmRow { n: 2 }).unwrap();
db.close().unwrap();
assert!(db.db_info().clean, "successful close publishes clean");
rows.put(&3u64.to_be_bytes(), &LsmRow { n: 3 }).unwrap();
assert!(
!db.db_info().clean,
"the first post-close LSM write must persistently demote clean before mutation"
);
lock(&db.collections).push(Arc::new(FailingLsmLengthProbe));
assert!(
db.close().is_err(),
"the second finalization is injected to fail"
);
assert_eq!(db.db_info().collections[LsmRow::NAME].seq, 1);
drop(rows);
}
let db = Db::open_test(dir.path()).unwrap();
assert!(!db.opened_clean);
let rows = db
.open_lsm_tree::<LsmRow, RapiraCodec, NoHook>(
None,
crate::lsm::LsmConfig::default(),
NoHook,
&[],
)
.unwrap();
assert_eq!(rows.len(), 3, "reopen must rescan rather than trust seq=1");
assert_eq!(rows.get(&3u64.to_be_bytes()), Some(LsmRow { n: 3 }));
}
#[cfg(feature = "fjall")]
#[test]
fn fjall_clean_marker_demotion_failure_aborts_write_and_retries() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let rows = db
.open_lsm_tree::<LsmRow, RapiraCodec, NoHook>(
Some(crate::lsm::LsmDbConfig::default()),
crate::lsm::LsmConfig::default(),
NoHook,
&[],
)
.unwrap();
rows.put(&1u64.to_be_bytes(), &LsmRow { n: 1 }).unwrap();
db.close().unwrap();
assert!(db.db_info().clean);
rows.fail_next_clean_marker_demotion_for_test();
assert!(rows.put(&2u64.to_be_bytes(), &LsmRow { n: 2 }).is_err());
assert!(rows.get(&2u64.to_be_bytes()).is_none());
assert!(
db.db_info().clean,
"a failed demotion leaves the shared marker clean so the next write retries"
);
rows.put(&2u64.to_be_bytes(), &LsmRow { n: 2 }).unwrap();
assert_eq!(rows.get(&2u64.to_be_bytes()), Some(LsmRow { n: 2 }));
assert!(!db.db_info().clean);
}
#[cfg(feature = "fjall")]
#[test]
fn fjall_unwind_after_clean_persistence_retries_demotion_before_mutation() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let rows = db
.open_lsm_tree::<LsmRow, RapiraCodec, NoHook>(
Some(crate::lsm::LsmDbConfig::default()),
crate::lsm::LsmConfig::default(),
NoHook,
&[],
)
.unwrap();
rows.put(&1u64.to_be_bytes(), &LsmRow { n: 1 }).unwrap();
rows.fail_next_clean_marker_demotion_for_test();
rows.panic_after_clean_marker_persistence_for_test();
assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| db.close())).is_err());
assert!(
db.db_info().clean,
"the injected unwind demotion failure leaves the persisted marker clean"
);
assert!(rows.get(&2u64.to_be_bytes()).is_none());
rows.put(&2u64.to_be_bytes(), &LsmRow { n: 2 }).unwrap();
assert_eq!(rows.get(&2u64.to_be_bytes()), Some(LsmRow { n: 2 }));
assert!(
!db.db_info().clean,
"the next writer must retry durable demotion before its mutation"
);
}
#[cfg(feature = "fjall")]
#[test]
fn fjall_finalization_blocks_a_write_until_clean_publication_then_demotes() {
use std::sync::mpsc;
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let rows = db
.open_lsm_tree::<LsmRow, RapiraCodec, NoHook>(
Some(crate::lsm::LsmDbConfig::default()),
crate::lsm::LsmConfig::default(),
NoHook,
&[],
)
.unwrap();
rows.put(&1u64.to_be_bytes(), &LsmRow { n: 1 }).unwrap();
let publication = rows.hold_clean_marker_publication_for_test();
let writer_entry = rows.hold_clean_marker_mutation_entry_for_test();
let (write_done_tx, write_done_rx) = mpsc::channel();
std::thread::scope(|scope| {
let finalizer = scope.spawn(|| db.close());
publication.wait_until_blocked();
assert!(
db.db_info().clean,
"the persistent clean marker is written before state publication"
);
let writer_rows = Arc::clone(&rows);
let writer = scope.spawn(move || {
let result = writer_rows.put(&2u64.to_be_bytes(), &LsmRow { n: 2 });
write_done_tx.send(result).unwrap();
});
writer_entry.wait_until_entered();
assert!(
write_done_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"a managed write must not pass the finalization transition lock"
);
assert!(rows.get(&2u64.to_be_bytes()).is_none());
publication.release();
finalizer.join().unwrap().unwrap();
write_done_rx
.recv_timeout(std::time::Duration::from_secs(1))
.unwrap()
.unwrap();
writer.join().unwrap();
});
assert_eq!(rows.get(&2u64.to_be_bytes()), Some(LsmRow { n: 2 }));
assert!(
!db.db_info().clean,
"the released write must durably demote clean before landing"
);
}
#[cfg(feature = "fjall")]
#[test]
fn pre_admitted_managed_lsm_write_blocks_finalization_until_keyspace_operation() {
use std::sync::mpsc;
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let rows = db
.open_lsm_tree::<LsmRow, RapiraCodec, NoHook>(
Some(crate::lsm::LsmDbConfig::default()),
crate::lsm::LsmConfig::default(),
NoHook,
&[],
)
.unwrap();
rows.put(&1u64.to_be_bytes(), &LsmRow { n: 1 }).unwrap();
let operation = rows.hold_clean_marker_mutation_operation_for_test();
let finalizer_entry = rows.hold_clean_marker_finalization_entry_for_test();
let (writer_done_tx, writer_done_rx) = mpsc::channel();
let (finalizer_done_tx, finalizer_done_rx) = mpsc::channel();
std::thread::scope(|scope| {
let writer_rows = Arc::clone(&rows);
let writer = scope.spawn(move || {
writer_done_tx
.send(writer_rows.put(&2u64.to_be_bytes(), &LsmRow { n: 2 }))
.unwrap();
});
operation.wait_until_blocked();
let finalizer = scope.spawn(|| {
finalizer_done_tx.send(db.close()).unwrap();
});
finalizer_entry.wait_until_entered();
let early_finalization = finalizer_done_rx
.recv_timeout(std::time::Duration::from_millis(100))
.ok();
operation.release();
writer_done_rx
.recv_timeout(std::time::Duration::from_secs(1))
.unwrap()
.unwrap();
match early_finalization.as_ref() {
Some(result) => assert!(result.is_ok()),
None => finalizer_done_rx
.recv_timeout(std::time::Duration::from_secs(1))
.unwrap()
.unwrap(),
}
writer.join().unwrap();
finalizer.join().unwrap();
assert!(
early_finalization.is_none(),
"finalization must wait for a Dirty writer admitted before its barriers"
);
});
assert_eq!(rows.get(&2u64.to_be_bytes()), Some(LsmRow { n: 2 }));
assert!(
db.db_info().clean,
"finalization must include the admitted write"
);
}
#[cfg(feature = "fjall")]
#[test]
fn retained_managed_lsm_tree_cannot_mutate_after_db_drop() {
let dir = tempfile::tempdir().unwrap();
let info_path = dir.path().join("db.info");
let rows = {
let db = Db::open_test(dir.path()).unwrap();
let rows = db
.open_lsm_tree::<LsmRow, RapiraCodec, NoHook>(
Some(crate::lsm::LsmDbConfig::default()),
crate::lsm::LsmConfig::default(),
NoHook,
&[],
)
.unwrap();
rows.put(&1u64.to_be_bytes(), &LsmRow { n: 1 }).unwrap();
db.close().unwrap();
assert!(db.db_info().clean);
rows
};
assert!(rows.put(&2u64.to_be_bytes(), &LsmRow { n: 2 }).is_err());
assert!(rows.get(&2u64.to_be_bytes()).is_none());
let info: DbInfo = serde_json::from_slice(&std::fs::read(info_path).unwrap()).unwrap();
assert!(
info.clean,
"the retained tree must not rewrite db.info after Db drop"
);
}
#[cfg(feature = "fjall")]
#[test]
fn retained_managed_lsm_tree_cannot_mutate_after_dirty_db_drop() {
let dir = tempfile::tempdir().unwrap();
let rows = {
let db = Db::open_test(dir.path()).unwrap();
let rows = db
.open_lsm_tree::<LsmRow, RapiraCodec, NoHook>(
Some(crate::lsm::LsmDbConfig::default()),
crate::lsm::LsmConfig::default(),
NoHook,
&[],
)
.unwrap();
rows.put(&1u64.to_be_bytes(), &LsmRow { n: 1 }).unwrap();
lock(&db.collections).push(Arc::new(FailingLsmLengthProbe));
assert!(
db.close().is_err(),
"injected close leaves the marker dirty"
);
assert!(!db.db_info().clean);
rows
};
assert!(rows.put(&2u64.to_be_bytes(), &LsmRow { n: 2 }).is_err());
assert!(rows.get(&2u64.to_be_bytes()).is_none());
}
struct ShutdownMock {
clean_calls: Arc<AtomicUsize>,
watch_closes: Arc<AtomicUsize>,
}
impl Collection for ShutdownMock {
fn name(&self) -> &str {
"shutdown_mock"
}
fn len(&self) -> usize {
0
}
fn compact(&self) -> DbResult<usize> {
Ok(0)
}
fn flush(&self) -> DbResult<()> {
Ok(())
}
fn periodic_flush(&self) -> DbResult<()> {
Ok(())
}
fn durable_flush(&self) -> DbResult<()> {
Ok(())
}
fn clean_shutdown(&self) -> DbResult<()> {
self.clean_calls.fetch_add(1, Ordering::SeqCst);
Err(DbError::Io(std::io::Error::other(
"mock clean shutdown failure",
)))
}
fn close_watches(&self) {
self.watch_closes.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn shutdown_uses_clean_finalization_and_closes_watches_after_error() {
let dir = tempfile::tempdir().unwrap();
let mut db = Db::open_test(dir.path()).unwrap();
let clean_calls = Arc::new(AtomicUsize::new(0));
let watch_closes = Arc::new(AtomicUsize::new(0));
lock(&db.collections).push(Arc::new(ShutdownMock {
clean_calls: Arc::clone(&clean_calls),
watch_closes: Arc::clone(&watch_closes),
}));
assert!(
db.shutdown().is_err(),
"clean finalization failure must surface"
);
assert_eq!(clean_calls.load(Ordering::SeqCst), 1);
assert_eq!(
watch_closes.load(Ordering::SeqCst),
1,
"watches must close even when finalization fails"
);
}
}
#[cfg(all(test, feature = "rapira-codec"))]
mod inspection_registry_tests {
use super::*;
use armour_core::GetType;
use rapira::Rapira;
use tempfile::tempdir;
use crate::{NoHook, RapiraCodec, TypedTree};
#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct AlphaItem {
n: u64,
}
impl CollectionMeta for AlphaItem {
type SelfId = [u8; 8];
const NAME: &'static str = "alpha_items";
const VERSION: u16 = 1;
}
#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct ZuluItem {
n: u64,
}
impl CollectionMeta for ZuluItem {
type SelfId = [u8; 8];
const NAME: &'static str = "zulu_items";
const VERSION: u16 = 1;
}
#[test]
fn inspection_registry_only_db_opened_collections() {
let dir = tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let _via_db = db
.open_typed_tree::<AlphaItem, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
let _direct = TypedTree::<[u8; 8], AlphaItem, RapiraCodec, NoHook>::open_hooked(
dir.path().join("direct_only"),
Config::test(),
RapiraCodec,
NoHook,
)
.unwrap();
let names: Vec<_> = lock(&db.inspection_handlers).keys().copied().collect();
assert_eq!(names, vec![AlphaItem::NAME]);
assert!(!names.contains(&"direct_only"));
}
#[test]
fn inspection_registry_collections_sorted_by_name() {
let dir = tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
let _zulu = db
.open_typed_tree::<ZuluItem, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
let _alpha = db
.open_typed_tree::<AlphaItem, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
let names: Vec<_> = db
.inspector()
.collections()
.unwrap()
.into_iter()
.map(|c| c.name)
.collect();
assert_eq!(
names,
vec![AlphaItem::NAME.to_owned(), ZuluItem::NAME.to_owned()]
);
}
}
#[cfg(test)]
mod inspection_rollback_tests {
use super::*;
use std::num::NonZeroU32;
use crate::armour::inspection::{
InspectionCollection, InspectionEntry, InspectionError, InspectionHandler, InspectionPage,
};
struct StubCollection(&'static str);
impl Collection for StubCollection {
fn name(&self) -> &str {
self.0
}
fn len(&self) -> usize {
0
}
fn compact(&self) -> DbResult<usize> {
Ok(0)
}
fn flush(&self) -> DbResult<()> {
Ok(())
}
fn periodic_flush(&self) -> DbResult<()> {
Ok(())
}
fn durable_flush(&self) -> DbResult<()> {
Ok(())
}
fn clean_shutdown(&self) -> DbResult<()> {
Ok(())
}
}
struct StubInspection;
impl InspectionHandler for StubInspection {
fn metadata(&self) -> Result<InspectionCollection, InspectionError> {
Err(InspectionError::CollectionNotFound {
name: "metadata-fail".into(),
})
}
fn get_raw(&self, _: &[u8]) -> DbResult<Option<Vec<u8>>> {
Ok(None)
}
fn exact_count(&self) -> DbResult<u64> {
Ok(0)
}
fn ordered_page(
&self,
_: &crate::armour::inspection::NormalizedBounds,
_: NonZeroU32,
) -> DbResult<InspectionPage> {
Ok(InspectionPage {
entries: vec![],
next_key: None,
})
}
fn unordered_snapshot(
&self,
_: NonZeroU32,
) -> Result<Vec<InspectionEntry>, InspectionError> {
Ok(vec![])
}
}
#[cfg(feature = "rpc")]
mod rpc_stubs {
use super::*;
use std::ops::Bound;
use crate::armour::handler::{KeyBytes, RowWriter, RpcHandler, ValueBytes};
use armour_rpc::protocol::WriteKey;
pub(super) struct StubRpc(pub &'static str);
impl RpcHandler for StubRpc {
fn name(&self) -> &str {
self.0
}
fn info(&self) -> (u64, u16) {
(0, 0)
}
fn schema(&self) -> armour_rpc::SchemaResponse {
armour_rpc::SchemaResponse {
name: self.0.to_owned(),
version: 0,
typ: <u8 as armour_core::GetType>::TYPE,
key_scheme: armour_core::KeyScheme::Bytes,
}
}
fn get_into(&self, _: &[u8], _: &mut Vec<u8>) -> DbResult<bool> {
Ok(false)
}
fn contains(&self, _: &[u8]) -> DbResult<bool> {
Ok(false)
}
fn entry_len(&self, _: &[u8]) -> DbResult<Option<u32>> {
Ok(None)
}
fn first_into(&self, _: &mut RowWriter<'_>) -> DbResult<bool> {
Ok(false)
}
fn last_into(&self, _: &mut RowWriter<'_>) -> DbResult<bool> {
Ok(false)
}
fn range_into(
&self,
_: Bound<KeyBytes>,
_: Bound<KeyBytes>,
_: &mut RowWriter<'_>,
) -> DbResult<u32> {
Ok(0)
}
fn range_keys_into(
&self,
_: Bound<KeyBytes>,
_: Bound<KeyBytes>,
_: &mut RowWriter<'_>,
) -> DbResult<u32> {
Ok(0)
}
fn write(&self, _: WriteKey, _: Option<bool>, _: ValueBytes) -> DbResult<ValueBytes> {
Ok(vec![])
}
fn remove(&self, _: &[u8], _: bool) -> DbResult<()> {
Ok(())
}
fn take_into(&self, _: &[u8], _: bool, _: &mut Vec<u8>) -> DbResult<bool> {
Ok(false)
}
fn count(&self, _: bool) -> DbResult<u64> {
Ok(0)
}
fn apply_batch(&self, _: Vec<(KeyBytes, Option<ValueBytes>)>) -> DbResult<()> {
Ok(())
}
}
}
fn publish_stub(db: &Db, name: &'static str, forced_hash: Option<u64>) -> DbResult<()> {
let collection: Arc<dyn Collection> = Arc::new(StubCollection(name));
let inspection: Arc<dyn InspectionHandler> = Arc::new(StubInspection);
#[cfg(feature = "rpc")]
let rpc =
Some(Arc::new(rpc_stubs::StubRpc(name)) as Arc<dyn crate::armour::handler::RpcHandler>);
db.publish_collection_for_test(
name,
collection,
inspection,
#[cfg(feature = "rpc")]
rpc,
forced_hash,
)
}
#[cfg(feature = "rpc")]
#[test]
fn inspection_rollback_hash_collision_leaves_registries_unchanged() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
publish_stub(&db, "alpha", None).unwrap();
let before_collections = lock(&db.collections).len();
let before_inspections = lock(&db.inspection_handlers).len();
#[cfg(feature = "rpc")]
let before_rpc = lock(&db.handlers).len();
let forced = xxhash_rust::xxh3::xxh3_64(b"alpha");
let error = publish_stub(&db, "beta", Some(forced)).unwrap_err();
assert!(matches!(error, DbError::DuplicateCollection(_)));
assert_eq!(lock(&db.collections).len(), before_collections);
assert_eq!(lock(&db.inspection_handlers).len(), before_inspections);
#[cfg(feature = "rpc")]
assert_eq!(lock(&db.handlers).len(), before_rpc);
}
#[test]
fn inspection_rollback_exact_name_collision_leaves_registries_unchanged() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_test(dir.path()).unwrap();
publish_stub(&db, "alpha", None).unwrap();
let before_collections = lock(&db.collections).len();
let before_inspections = lock(&db.inspection_handlers).len();
#[cfg(feature = "rpc")]
let before_rpc = lock(&db.handlers).len();
let error = publish_stub(&db, "alpha", None).unwrap_err();
assert!(matches!(error, DbError::DuplicateCollection(_)));
assert_eq!(lock(&db.collections).len(), before_collections);
assert_eq!(lock(&db.inspection_handlers).len(), before_inspections);
#[cfg(feature = "rpc")]
assert_eq!(lock(&db.handlers).len(), before_rpc);
}
}
#[cfg(all(test, feature = "rapira-codec"))]
mod db_builder_tests {
use std::sync::Arc;
use super::*;
use crate::{CollectionMeta, Config, NoHook, RapiraCodec, TxConfig};
use armour_core::GetType;
use rapira::Rapira;
#[derive(Clone, Copy, Debug, PartialEq, Eq, GetType, Rapira)]
struct Row {
n: u32,
}
impl CollectionMeta for Row {
type SelfId = [u8; 8];
const NAME: &'static str = "rows";
}
#[test]
fn builder_defaults_match_open() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::builder(dir.path()).open().unwrap());
let rows = db
.open_typed_map::<Row, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
rows.put(&1u64.to_be_bytes(), Row { n: 7 }).unwrap();
assert_eq!(rows.get(&1u64.to_be_bytes()).map(|r| r.n), Some(7));
}
#[test]
fn builder_carries_workers_and_tx_config_together() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(
Db::builder(dir.path())
.for_tests()
.tx_config(TxConfig {
max_ops: 2,
..TxConfig::default()
})
.open()
.unwrap(),
);
let rows = db
.open_typed_map::<Row, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
let err = match db
.tx()
.put(&rows, &1u64.to_be_bytes(), &Row { n: 1 })
.unwrap()
.put(&rows, &2u64.to_be_bytes(), &Row { n: 2 })
.unwrap()
.put(&rows, &3u64.to_be_bytes(), &Row { n: 3 })
{
Ok(_) => panic!("expected the third put to exceed max_ops"),
Err(e) => e,
};
assert!(matches!(err, crate::DbError::Client(_)), "{err}");
assert!(rows.get(&1u64.to_be_bytes()).is_none(), "nothing written");
}
#[test]
fn builder_carries_discard_pending_on_schema_mismatch() {
let dir = tempfile::tempdir().unwrap();
let db = Db::builder(dir.path())
.for_tests()
.tx_config(TxConfig {
discard_pending_on_schema_mismatch: true,
..TxConfig::default()
})
.open()
.unwrap();
assert!(db.tx_config().discard_pending_on_schema_mismatch);
}
#[test]
fn for_tests_is_a_preset_and_later_calls_override_it() {
let dir = tempfile::tempdir().unwrap();
let db = Db::builder(dir.path())
.for_tests()
.workers(WorkerIntervals::default())
.open()
.unwrap();
assert!(db.compactor.is_some(), "workers() re-enabled compaction");
assert!(
db.flusher.is_some(),
"workers() re-enabled the flush worker"
);
assert!(db.sweeper.is_some(), "workers() re-enabled the TTL sweeper");
drop(db);
}
}