armdb 0.2.0

sharded bitcask key-value storage optimized for NVMe
Documentation
use crate::error::{DbError, DbResult};

/// Database configuration.
///
/// # Immutable vs tunable parameters
///
/// **Immutable** (fixed at creation, changing breaks the database):
/// - `shard_count` — stored in `db.meta`, determines key→shard routing
/// - `shard_prefix_bits` — determines key→shard grouping; changing breaks
///   `atomic` guarantees and dead_bytes tracking across shards
/// - `encryption_key` — presence/absence stored in `db.meta`
///
/// **Tunable** (safe to change between restarts):
/// - `max_file_size`, `compaction_threshold`, `enable_fsync`, `write_buffer_size`
/// - `cache` — in-memory only, no on-disk state
/// - `reversed` — only affects in-memory index ordering
/// - `hints` — controls hint file generation/use during recovery
#[derive(Debug, Clone)]
#[cfg_attr(
    any(feature = "armour", feature = "postcard-codec"),
    derive(serde::Deserialize)
)]
pub struct Config {
    /// Number of shards. **Immutable** — stored in `db.meta`, changing requires
    /// recreating the database. Default: `available_parallelism()`.
    pub shard_count: usize,
    /// Maximum size of a single data file before rotation. Tunable.
    /// Default: 256 MB.
    pub max_file_size: u64,
    /// Ratio of dead_bytes / total_bytes required to trigger compaction. Tunable.
    /// Default: 0.3.
    pub compaction_threshold: f64,
    /// Whether to fsync after writes. Tunable. Default: false.
    pub enable_fsync: bool,
    /// Write buffer size per shard in bytes. Tunable. Writes are buffered in
    /// memory and flushed to disk when the buffer is full or on explicit
    /// flush/close. Default: 1 MB.
    pub write_buffer_size: usize,
    /// Value cache configuration (for VarTree). Tunable — in-memory only.
    #[cfg(feature = "var-collections")]
    pub cache: CacheConfig,
    /// Number of prefix bits of the key to use for shard routing. **Immutable** —
    /// changing breaks `atomic` guarantees (keys regroup across shards) and
    /// dead_bytes tracking (old entries attributed to wrong shard).
    /// 0 = hash full key (default). Non-zero = hash first N bits for locality.
    pub shard_prefix_bits: usize,
    /// Reverse key ordering in the index. Tunable — only affects in-memory
    /// index ordering. When `true`, forward iteration yields keys in
    /// descending order (newest first for monotonic IDs). Default: true.
    pub reversed: bool,
    /// Generate and use hint files for faster recovery. Tunable.
    /// When `true`, hint files are written on graceful shutdown and file
    /// rotation, and used during recovery to skip full data scans.
    /// Recommended `true` for VarTree (avoids decoding values on recovery),
    /// `false` for ConstTree/TypedTree (values are small, full scan is fast).
    /// Default: false.
    pub hints: bool,
    /// 32-byte AES-256 encryption key. **Immutable** — presence/absence is
    /// stored in `db.meta`. `None` = no encryption.
    /// Use `PageCipher::key_from_env("ARMDB_KEY")` to read from environment.
    #[cfg(feature = "encryption")]
    pub encryption_key: Option<[u8; 32]>,
}

/// Configuration for the value cache used by `VarTree`.
#[cfg(feature = "var-collections")]
#[derive(Debug, Clone)]
#[cfg_attr(
    any(feature = "armour", feature = "postcard-codec"),
    derive(serde::Deserialize)
)]
pub struct CacheConfig {
    /// Maximum cache size in bytes. 0 = disabled. Default: 0.
    pub max_size: u64,
    /// Estimated number of items (for hash table pre-allocation). Default: 100_000.
    pub estimated_items: usize,
}

impl Config {
    /// 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 {
        Self {
            shard_count: 2,
            hints: true,
            ..Self::default()
        }
    }

    pub fn validate(&self) -> DbResult<()> {
        if self.shard_count == 0 || self.shard_count > 255 {
            return Err(DbError::Config("shard_count must be between 1 and 255"));
        }
        if self.max_file_size < 4096 {
            return Err(DbError::Config("max_file_size must be at least 4096"));
        }
        if self.write_buffer_size < 4096 {
            return Err(DbError::Config("write_buffer_size must be at least 4096"));
        }
        if self.max_file_size > u32::MAX as u64 {
            return Err(DbError::Config(
                "max_file_size must not exceed u32::MAX (4 GiB)",
            ));
        }
        if (self.write_buffer_size as u64) > (u32::MAX as u64) - 4096 {
            return Err(DbError::Config(
                "write_buffer_size must not exceed u32::MAX - 4096",
            ));
        }
        if (self.write_buffer_size as u64) > self.max_file_size {
            return Err(DbError::Config(
                "max_file_size must be >= write_buffer_size",
            ));
        }
        if self.shard_prefix_bits > u8::MAX as usize {
            return Err(DbError::Config("shard_prefix_bits must be <= 255"));
        }
        if !self.compaction_threshold.is_finite()
            || !(0.0..=1.0).contains(&self.compaction_threshold)
        {
            return Err(DbError::Config(
                "compaction_threshold must be a finite value in [0.0, 1.0]",
            ));
        }
        #[cfg(feature = "encryption")]
        if self.encryption_key.is_some() {
            if self.write_buffer_size < 8192 {
                return Err(DbError::Config(
                    "write_buffer_size must be at least 8192 when encryption is enabled",
                ));
            }
            if !self.write_buffer_size.is_multiple_of(4096) {
                return Err(DbError::Config(
                    "write_buffer_size must be a multiple of 4096 when encryption is enabled",
                ));
            }
        }
        Ok(())
    }
}

impl Default for Config {
    fn default() -> Self {
        #[cfg(debug_assertions)]
        let shard_count = std::thread::available_parallelism()
            .map(|p| p.get() / 2)
            .unwrap_or(2)
            .clamp(1, 4);
        #[cfg(not(debug_assertions))]
        let shard_count = std::thread::available_parallelism()
            .map(|p| p.get() / 2)
            .unwrap_or(4)
            .clamp(1, 8);
        Self {
            shard_count,
            max_file_size: 256 * 1024 * 1024,
            compaction_threshold: 0.3,
            enable_fsync: false,
            write_buffer_size: 1024 * 1024, // 1 MB
            #[cfg(feature = "var-collections")]
            cache: CacheConfig::default(),
            shard_prefix_bits: 0,
            reversed: true,
            hints: false,
            #[cfg(feature = "encryption")]
            encryption_key: None,
        }
    }
}

#[cfg(feature = "var-collections")]
impl Default for CacheConfig {
    fn default() -> Self {
        Self {
            max_size: 0,
            estimated_items: 100_000,
        }
    }
}

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

    fn default_for_test() -> Config {
        Config {
            shard_count: 2,
            max_file_size: 256 * 1024 * 1024,
            compaction_threshold: 0.3,
            enable_fsync: false,
            write_buffer_size: 1024 * 1024,
            #[cfg(feature = "var-collections")]
            cache: CacheConfig::default(),
            shard_prefix_bits: 0,
            reversed: true,
            hints: false,
            #[cfg(feature = "encryption")]
            encryption_key: None,
        }
    }

    #[test]
    fn test_default_config_is_valid() {
        let cfg = Config::default();
        assert!(cfg.shard_count >= 1);
        assert!(cfg.validate().is_ok());
    }

    #[test]
    fn validate_rejects_max_file_size_smaller_than_write_buffer() {
        let mut cfg = default_for_test();
        cfg.max_file_size = 4096;
        cfg.write_buffer_size = 8192;
        assert!(cfg.validate().is_err());
    }

    #[test]
    fn validate_rejects_write_buffer_at_u32_limit() {
        let mut cfg = default_for_test();
        cfg.write_buffer_size = (u32::MAX as usize) - 4095;
        cfg.max_file_size = u32::MAX as u64;
        assert!(cfg.validate().is_err());
    }

    #[cfg(feature = "encryption")]
    #[test]
    fn validate_rejects_small_write_buffer_under_encryption() {
        let mut cfg = default_for_test();
        cfg.encryption_key = Some([0u8; 32]);
        cfg.write_buffer_size = 4096;
        assert!(cfg.validate().is_err());
    }

    #[cfg(feature = "encryption")]
    #[test]
    fn validate_rejects_non_page_multiple_write_buffer_under_encryption() {
        let mut cfg = default_for_test();
        cfg.encryption_key = Some([0u8; 32]);
        cfg.write_buffer_size = 8192 + 100;
        cfg.max_file_size = 256 * 1024 * 1024;
        assert!(cfg.validate().is_err());
    }

    #[cfg(feature = "encryption")]
    #[test]
    fn validate_accepts_page_multiple_8k_under_encryption() {
        let mut cfg = default_for_test();
        cfg.encryption_key = Some([0u8; 32]);
        cfg.write_buffer_size = 8192;
        cfg.max_file_size = 256 * 1024 * 1024;
        assert!(cfg.validate().is_ok());
    }

    #[test]
    fn validate_accepts_plain_4096_write_buffer() {
        let mut cfg = default_for_test();
        cfg.write_buffer_size = 4096;
        cfg.max_file_size = 8192;
        assert!(cfg.validate().is_ok());
    }

    #[test]
    fn validate_rejects_shard_prefix_bits_over_255() {
        let mut cfg = default_for_test();
        cfg.shard_prefix_bits = 256;
        let err = cfg.validate().unwrap_err();
        assert!(
            err.to_string().contains("shard_prefix_bits"),
            "expected shard_prefix_bits error, got: {err}"
        );
    }

    #[test]
    fn validate_rejects_compaction_threshold_nan() {
        let mut cfg = default_for_test();
        cfg.compaction_threshold = f64::NAN;
        assert!(cfg.validate().is_err());
    }

    #[test]
    fn validate_rejects_compaction_threshold_negative() {
        let mut cfg = default_for_test();
        cfg.compaction_threshold = -0.1;
        assert!(cfg.validate().is_err());
    }

    #[test]
    fn validate_rejects_compaction_threshold_over_one() {
        let mut cfg = default_for_test();
        cfg.compaction_threshold = 1.1;
        assert!(cfg.validate().is_err());
    }

    #[test]
    fn validate_accepts_compaction_threshold_half() {
        let mut cfg = default_for_test();
        cfg.compaction_threshold = 0.5;
        assert!(cfg.validate().is_ok());
    }

    #[test]
    fn validate_accepts_compaction_threshold_boundaries() {
        let mut cfg = default_for_test();
        cfg.compaction_threshold = 0.0;
        assert!(cfg.validate().is_ok());
        cfg.compaction_threshold = 1.0;
        assert!(cfg.validate().is_ok());
    }
}