armdb 0.1.16

sharded bitcask key-value storage optimized for NVMe
Documentation
//! Regression tests for the Var Collections Critical Correctness fix slice.
//! See armdb/docs/superpowers/specs/26-05-15-var-collections-critical-correctness.md.

#![cfg(feature = "var-collections")]

use armdb::{Config, DbError, VarMap, VarTree};
use tempfile::tempdir;

#[test]
fn var_tree_duplicate_insert_returns_key_exists_and_preserves_value() {
    let dir = tempdir().unwrap();
    let tree = VarTree::<[u8; 8]>::open(dir.path(), Config::test()).unwrap();

    let key = 1u64.to_be_bytes();
    tree.insert(&key, b"original").unwrap();

    let err = tree
        .insert(&key, b"replacement")
        .expect_err("duplicate insert must fail");
    assert!(matches!(err, DbError::KeyExists));

    let got = tree.get(&key).expect("original value must still exist");
    assert_eq!(got.as_bytes(), b"original");
}

#[test]
fn var_tree_oversized_value_returns_client_error() {
    let dir = tempdir().unwrap();
    let mut config = Config::test();
    config.write_buffer_size = 4096;
    let tree = VarTree::<[u8; 8]>::open(dir.path(), config).unwrap();

    let key = 7u64.to_be_bytes();
    // write_buffer_size = 4096; a 4096-byte value plus header+key always
    // exceeds the buffer. Should be rejected without panic.
    let big_value = vec![0xAB; 4096];
    let err = tree
        .put(&key, &big_value)
        .expect_err("oversized value must be rejected");
    assert!(
        matches!(err, DbError::Client(msg) if msg.contains("write_buffer_size")),
        "expected DbError::Client describing write_buffer_size, got: {err:?}",
    );

    // The tree must still be usable for normal-sized writes after rejection.
    tree.put(&key, b"normal").unwrap();
    let got = tree.get(&key).expect("normal put must succeed");
    assert_eq!(got.as_bytes(), b"normal");
}

#[test]
fn var_map_oversized_value_returns_client_error() {
    let dir = tempdir().unwrap();
    let mut config = Config::test();
    config.write_buffer_size = 4096;
    let map = VarMap::<[u8; 8]>::open(dir.path(), config).unwrap();

    let key = 11u64.to_be_bytes();
    let big_value = vec![0xCD; 4096];
    let err = map
        .put(&key, &big_value)
        .expect_err("oversized value must be rejected");
    assert!(
        matches!(err, DbError::Client(msg) if msg.contains("write_buffer_size")),
        "expected DbError::Client describing write_buffer_size, got: {err:?}",
    );

    map.put(&key, b"normal").unwrap();
    let got = map.get(&key).expect("normal put must succeed");
    assert_eq!(got.as_bytes(), b"normal");
}

#[cfg(feature = "encryption")]
#[test]
fn var_tree_locked_read_returns_plaintext_after_flush() {
    let dir = tempdir().unwrap();
    let mut config = Config::test();
    // shard_count=1: predictable routing. max_file_size=4096 forces rotation
    // because the single entry's value > 4072 bytes already exceeds the page;
    // the source file is force-flushed encrypted and becomes immutable.
    config.shard_count = 1;
    config.max_file_size = 4096;
    config.write_buffer_size = 8192;
    config.encryption_key = Some([0x42; 32]);
    let tree = VarTree::<[u8; 8]>::open(dir.path(), config).unwrap();

    let key = 99u64.to_be_bytes();
    // 4073 > 4096 - (EntryHeader + key_len) = 4072 — the value straddles a
    // 4096-byte block boundary inside the data file, so read_value_locked's
    // single-block cache fast path is geometrically skipped (start+len>4096).
    let value = vec![0xEFu8; 4073];
    tree.put(&key, &value).unwrap();

    // One filler write on the new active file keeps the immutable file's
    // disk state stable and exercises a normal-sized put after rotation.
    let filler_key = 100u64.to_be_bytes();
    tree.put(&filler_key, &vec![0u8; 1000]).unwrap();

    // cas() goes through read_value_locked under the shard lock. File 1 is
    // immutable; block cache is cold so the check falls through to step 3
    // (raw pread_value). Without the fix, pread_value returns ciphertext and
    // the comparison with plaintext `value` fails → CasMismatch / KeyNotFound.
    let new_value = b"plain-new".to_vec();
    tree.cas(&key, &value, &new_value)
        .expect("cas with plaintext expected value must succeed");

    let got = tree.get(&key).expect("key must still exist");
    assert_eq!(got.as_bytes(), new_value.as_slice());
}

#[cfg(feature = "encryption")]
#[test]
fn var_map_locked_read_returns_plaintext_after_flush() {
    let dir = tempdir().unwrap();
    let mut config = Config::test();
    // Same geometry as the VarTree test: a single >4072-byte value forces
    // rotation, and the value straddles a 4096-byte block in the data file
    // so the single-block cache fast path is geometrically skipped.
    config.shard_count = 1;
    config.max_file_size = 4096;
    config.write_buffer_size = 8192;
    config.encryption_key = Some([0x77; 32]);
    let map = VarMap::<[u8; 8]>::open(dir.path(), config).unwrap();

    let key = 33u64.to_be_bytes();
    let value = vec![0xABu8; 4073];

    map.put(&key, &value).unwrap();
    map.put(&44u64.to_be_bytes(), &vec![0u8; 1000]).unwrap();

    // After rotation, key=33 is in immutable file 1. Block cache is cold.
    // read_value_locked falls through to raw pread_value → ciphertext (without fix).
    let new_value = b"plain-new".to_vec();
    map.cas(&key, &value, &new_value)
        .expect("cas with plaintext expected value must succeed");

    let got = map.get(&key).expect("key must still exist");
    assert_eq!(got.as_bytes(), new_value.as_slice());
}