armdb 0.6.0

sharded bitcask key-value storage optimized for NVMe
Documentation
use std::time::Duration;

use crate::error::{DbError, DbResult};

/// Configuration for the fixed-slot storage engine.
///
/// # Immutable vs tunable parameters
///
/// **Immutable** (fixed at creation):
/// - `shard_count` — determines key-to-shard routing
/// - `shard_prefix_bits` — determines key-to-shard grouping
///
/// **Tunable** (safe to change between restarts):
/// - `grow_step`, `sync_interval`, `sync_batch_size`, `enable_fsync`, `reversed`
#[derive(Debug, Clone)]
pub struct FixedConfig {
    /// Number of shards. **Immutable** — changing requires recreating the
    /// database. Default: `available_parallelism()`.
    pub shard_count: usize,
    /// Number of prefix bits of the key to use for shard routing. **Immutable**.
    /// 0 = hash full key (default). Non-zero = hash first N bits for locality.
    pub shard_prefix_bits: usize,
    /// Number of slots to pre-allocate when the file grows. Tunable.
    /// Default: 1_000_000.
    pub grow_step: u32,
    /// Sync threshold by time — a **write-triggered** threshold (checked after
    /// each mutation, not a background timer): a write triggers an fdatasync
    /// when this much time has elapsed since the last sync. Idle tail writes
    /// below `sync_batch_size` are bounded by an explicit `flush()`/`close()`
    /// or the `Db` periodic flusher. Tunable. Default: 50 ms.
    pub sync_interval: Duration,
    /// Sync threshold by count — a **write-triggered** threshold: a write
    /// triggers an fdatasync once this many writes have accumulated since the
    /// last sync. Tunable. Default: 1024.
    pub sync_batch_size: u32,
    /// Whether to fsync after writes. Tunable. Default: false.
    pub enable_fsync: bool,
    /// Build the in-memory companion key index that enables ordered iteration
    /// (`iter`/`range`/`paginate`/`retain` via `iter_view()`). Tunable,
    /// **in-memory only** — not stored in `db.meta`, so it can be toggled freely
    /// across reopens. Costs extra memory (a per-shard BTreeSet of keys) and
    /// O(log N) per write. Default: false.
    ///
    /// Opt in only for **occasional admin/maintenance** scans (browsing a
    /// collection, bulk-cleaning stale rows) — not for hot-path reads. A Map's
    /// strength is O(1) point lookup; if scans are part of the normal workload,
    /// use a `*Tree` collection instead. Map iteration is a batched k-way merge
    /// that releases shard locks between batches, so it is **not a point-in-time
    /// snapshot** like a `Tree` scan: concurrent writes may be only partially
    /// observed.
    pub iterable: bool,
    /// Iteration direction for ordered scans. `true` (default) = DESC
    /// (largest key / newest first); `false` = ASC. **Tunable**, in-memory
    /// only — not stored in `db.meta`, so it can be changed freely across
    /// reopens (the index is rebuilt from disk on recovery and the on-disk
    /// byte layout is unchanged). Mirrors `Config::reversed`.
    pub reversed: bool,
}

/// FixedStore use-case presets for [`FixedConfig`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FixedWorkload {
    /// General-purpose preset for mixed workloads.
    Balanced,
    /// Counters / metrics: frequent increments, durability not critical.
    Counters,
    /// Rate limiters / sessions: medium durability.
    Sessions,
    /// Financial data: max durability (fsync per write).
    Financial,
}

impl FixedWorkload {
    fn base(self) -> FixedConfig {
        let common = FixedConfig {
            shard_count: crate::config::default_shard_count(),
            shard_prefix_bits: 0,
            grow_step: 1_000_000,
            sync_interval: Duration::from_millis(50),
            sync_batch_size: 1024,
            enable_fsync: false,
            iterable: false,
            reversed: true,
        };
        match self {
            FixedWorkload::Balanced => common,
            FixedWorkload::Counters => FixedConfig {
                shard_count: 8,
                sync_interval: Duration::from_millis(200),
                sync_batch_size: 5000,
                ..common
            },
            FixedWorkload::Sessions => FixedConfig {
                shard_count: 16,
                grow_step: 500_000,
                sync_interval: Duration::from_millis(20),
                ..common
            },
            FixedWorkload::Financial => FixedConfig {
                shard_count: 4,
                grow_step: 100_000,
                sync_interval: Duration::from_millis(1),
                sync_batch_size: 1,
                enable_fsync: true,
                ..common
            },
        }
    }
}

#[bon::bon]
impl FixedConfig {
    /// Build a `FixedConfig` from a [`FixedWorkload`] preset; any field is
    /// overridable via the chained setters, unset fields keep the preset value.
    #[builder(builder_type = FixedConfigBuilder, finish_fn = build)]
    pub fn for_workload(
        #[builder(start_fn)] workload: FixedWorkload,
        shard_count: Option<usize>,
        shard_prefix_bits: Option<usize>,
        grow_step: Option<u32>,
        sync_interval: Option<Duration>,
        sync_batch_size: Option<u32>,
        enable_fsync: Option<bool>,
        iterable: Option<bool>,
        reversed: Option<bool>,
    ) -> FixedConfig {
        let b = workload.base();
        FixedConfig {
            shard_count: shard_count.unwrap_or(b.shard_count),
            shard_prefix_bits: shard_prefix_bits.unwrap_or(b.shard_prefix_bits),
            grow_step: grow_step.unwrap_or(b.grow_step),
            sync_interval: sync_interval.unwrap_or(b.sync_interval),
            sync_batch_size: sync_batch_size.unwrap_or(b.sync_batch_size),
            enable_fsync: enable_fsync.unwrap_or(b.enable_fsync),
            iterable: iterable.unwrap_or(b.iterable),
            reversed: reversed.unwrap_or(b.reversed),
        }
    }
}

impl FixedConfig {
    /// Returns a builder initialized with the [`FixedWorkload::Balanced`] preset.
    pub fn balanced() -> FixedConfigBuilder {
        Self::for_workload(FixedWorkload::Balanced)
    }
    /// Returns a builder initialized with the [`FixedWorkload::Counters`] preset.
    pub fn counters() -> FixedConfigBuilder {
        Self::for_workload(FixedWorkload::Counters)
    }
    /// Returns a builder initialized with the [`FixedWorkload::Sessions`] preset.
    pub fn sessions() -> FixedConfigBuilder {
        Self::for_workload(FixedWorkload::Sessions)
    }
    /// Returns a builder initialized with the [`FixedWorkload::Financial`] preset.
    pub fn financial() -> FixedConfigBuilder {
        Self::for_workload(FixedWorkload::Financial)
    }

    /// Config with small shard count for tests. Keeps fd usage low
    /// so `cargo test` doesn't hit "Too many open files" on default ulimit.
    pub fn test() -> Self {
        FixedConfig::for_workload(FixedWorkload::Balanced)
            .shard_count(3)
            .grow_step(1_000)
            .sync_interval(Duration::from_millis(10))
            .sync_batch_size(100)
            .build()
    }

    pub fn validate(&self) -> DbResult<()> {
        if self.shard_count == 0 || self.shard_count > 255 {
            return Err(DbError::Config("shard_count must be 1..=255"));
        }
        if self.grow_step == 0 {
            return Err(DbError::Config("grow_step must be > 0"));
        }
        if self.sync_batch_size == 0 {
            return Err(DbError::Config("sync_batch_size must be > 0"));
        }
        if self.shard_prefix_bits > u8::MAX as usize {
            return Err(DbError::Config("shard_prefix_bits must be <= 255"));
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config_is_valid() {
        FixedConfig::balanced().build().validate().unwrap();
    }

    #[test]
    fn test_test_config_is_valid() {
        FixedConfig::test().validate().unwrap();
    }

    #[test]
    fn test_invalid_shard_count() {
        let mut c = FixedConfig::test();
        c.shard_count = 0;
        assert!(c.validate().is_err());
        c.shard_count = 256;
        assert!(c.validate().is_err());
    }

    #[test]
    fn test_invalid_grow_step() {
        let mut c = FixedConfig::test();
        c.grow_step = 0;
        assert!(c.validate().is_err());
    }

    #[test]
    fn test_invalid_sync_batch_size() {
        let mut c = FixedConfig::test();
        c.sync_batch_size = 0;
        assert!(c.validate().is_err());
    }

    #[test]
    fn test_invalid_shard_prefix_bits() {
        let mut c = FixedConfig::test();
        c.shard_prefix_bits = 256;
        assert!(c.validate().is_err());
        let msg = c.validate().unwrap_err().to_string();
        assert!(
            msg.contains("shard_prefix_bits"),
            "expected shard_prefix_bits error, got: {msg}"
        );
    }

    #[test]
    fn test_default_shard_count_at_least_one() {
        let cfg = FixedConfig::balanced().build();
        assert!(
            cfg.shard_count >= 1,
            "shard_count must be >= 1, got {}",
            cfg.shard_count
        );
        cfg.validate().unwrap();
    }

    #[test]
    fn fixed_counters_matches_table() {
        let c = FixedConfig::counters().build();
        assert_eq!(c.shard_count, 8);
        assert_eq!(c.grow_step, 1_000_000);
        assert_eq!(c.sync_interval, Duration::from_millis(200));
        assert_eq!(c.sync_batch_size, 5000);
        assert!(!c.enable_fsync);
        c.validate().unwrap();
    }

    #[test]
    fn fixed_financial_fsync_on() {
        let c = FixedConfig::financial().build();
        assert_eq!(c.shard_count, 4);
        assert_eq!(c.grow_step, 100_000);
        assert_eq!(c.sync_interval, Duration::from_millis(1));
        assert_eq!(c.sync_batch_size, 1);
        assert!(c.enable_fsync);
        c.validate().unwrap();
    }

    #[test]
    fn fixed_for_workload_override() {
        let c = FixedConfig::for_workload(FixedWorkload::Sessions)
            .shard_count(2)
            .build();
        assert_eq!(c.shard_count, 2);
        assert_eq!(c.sync_interval, Duration::from_millis(20));
    }

    #[test]
    fn fixed_iterable_defaults_false_and_overridable() {
        assert!(!FixedConfig::balanced().build().iterable);
        assert!(FixedConfig::balanced().iterable(true).build().iterable);
    }

    #[test]
    fn fixed_reversed_defaults_true_and_overridable() {
        assert!(
            FixedConfig::balanced().build().reversed,
            "default must be DESC (reversed = true)"
        );
        assert!(!FixedConfig::balanced().reversed(false).build().reversed);
        // uniform across presets
        assert!(FixedConfig::counters().build().reversed);
        assert!(FixedConfig::sessions().build().reversed);
        assert!(FixedConfig::financial().build().reversed);
        assert!(FixedConfig::test().reversed);
    }
}