armdb 0.2.0

sharded bitcask key-value storage optimized for NVMe
Documentation
//! Integration tests for `Db::run_migration` multi-step walk and
//! schema-mismatch detection (V-001, V-002, V-005, V-006).
//!
//! Feature gates: the armour + typed-tree + rapira-codec features must all be
//! active (same as `db_close_flush_tests.rs`).
#![cfg(all(feature = "typed-tree", feature = "armour", feature = "rapira-codec"))]

use armdb::armour::Db;
use armdb::{
    CollectionMeta, Config, DbError, MigrateAction, NoHook, RapiraCodec, SchemaMismatchKind,
};
use armour_core::GetType;
use rapira::Rapira;
use tempfile::tempdir;

// ---------------------------------------------------------------------------
// Test fixtures
//
// We need several struct families that share a NAME so they open the same
// on-disk collection but carry different VERSION constants (and in the
// typ-hash test, different fields).
//
// Family A: "mig_test_items" — V1, V2, V3
//   All have the same fields (value: u64) so the serialisation layout is the
//   same across versions. This lets migration fns read V1 data as V3 without
//   needing to re-define the on-disk format.
//
// Family B: "mig_hash_items" — two structs with the same VERSION=1 but
//   different field layouts, giving different GetType hashes.
//   Used for the typ_hash drift test.
// ---------------------------------------------------------------------------

// ── Family A ──────────────────────────────────────────────────────────────

#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct ItemV1 {
    value: u64,
}

impl CollectionMeta for ItemV1 {
    type SelfId = [u8; 8];
    const NAME: &'static str = "mig_test_items";
    const VERSION: u16 = 1;
}

#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct ItemV2 {
    value: u64,
}

impl CollectionMeta for ItemV2 {
    type SelfId = [u8; 8];
    const NAME: &'static str = "mig_test_items";
    const VERSION: u16 = 2;
}

#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct ItemV3 {
    value: u64,
}

impl CollectionMeta for ItemV3 {
    type SelfId = [u8; 8];
    const NAME: &'static str = "mig_test_items";
    const VERSION: u16 = 3;
}

// ── Family B ──────────────────────────────────────────────────────────────
// Same NAME and VERSION=1, but different fields → different GetType hash.

#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct HashItemA {
    value: u64,
}

impl CollectionMeta for HashItemA {
    type SelfId = [u8; 8];
    const NAME: &'static str = "mig_hash_items";
    const VERSION: u16 = 1;
}

#[derive(Clone, Debug, PartialEq, Rapira, GetType)]
struct HashItemB {
    /// Different field layout: two fields instead of one.
    value: u64,
    extra: u32,
}

impl CollectionMeta for HashItemB {
    type SelfId = [u8; 8];
    const NAME: &'static str = "mig_hash_items";
    const VERSION: u16 = 1;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn key(id: u64) -> [u8; 8] {
    id.to_be_bytes()
}

fn unwrap_err<T>(r: Result<T, DbError>) -> DbError {
    match r {
        Err(e) => e,
        Ok(_) => panic!("expected Err, got Ok"),
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

/// Two-step migration V1→V2→V3 must apply both steps in order.
///
/// All three versions share the same on-disk layout (value: u64), so the
/// migration callbacks operate on ItemV3 for both steps — that is the type
/// used when opening.  We add 1 per step: starting value=1, V1→V2 adds 1,
/// V2→V3 adds 1 again, final value=3.
///
/// The second reopen (version already at 3) must NOT run any migration.
#[test]
fn migration_multi_step_walks_intermediate_versions() {
    let dir = tempdir().unwrap();

    // Phase 1: open with V1, insert two entries (value = 1).
    {
        let db = Db::open_test(dir.path()).unwrap();
        let tree = db
            .open_typed_tree::<ItemV1, RapiraCodec, _>(Config::test(), NoHook, &[])
            .unwrap();
        tree.put(&key(0), ItemV1 { value: 1 }).unwrap();
        tree.put(&key(1), ItemV1 { value: 1 }).unwrap();
        db.close().unwrap();
    }

    // Migration functions for the V3 opener.
    // Both callbacks have type fn(&[u8;8], &ItemV3) -> MigrateAction<ItemV3>
    // because that is what open_typed_tree::<ItemV3, ...> expects.
    // The V1/V2 on-disk data decodes fine as ItemV3 (same layout).
    fn add_one_step(_k: &[u8; 8], v: &ItemV3) -> MigrateAction<ItemV3> {
        MigrateAction::Update(ItemV3 { value: v.value + 1 })
    }

    #[allow(clippy::type_complexity)]
    let migrations: &[(u16, fn(&[u8; 8], &ItemV3) -> MigrateAction<ItemV3>)] =
        &[(1, add_one_step), (2, add_one_step)];

    // Phase 2: reopen with V3 and two migration steps.
    {
        let db = Db::open_test(dir.path()).unwrap();
        let tree = db
            .open_typed_tree::<ItemV3, RapiraCodec, _>(Config::test(), NoHook, migrations)
            .unwrap();
        // Each entry was incremented twice: 1 → 2 → 3.
        assert_eq!(
            tree.get(&key(0)).unwrap().value,
            3,
            "entry 0 must reach v=3"
        );
        assert_eq!(
            tree.get(&key(1)).unwrap().value,
            3,
            "entry 1 must reach v=3"
        );
        db.close().unwrap();
    }

    // Phase 3: reopen with V3 again — stored version is already 3 so no
    // migration must run; values remain 3.
    {
        let db = Db::open_test(dir.path()).unwrap();
        let tree = db
            .open_typed_tree::<ItemV3, RapiraCodec, _>(Config::test(), NoHook, migrations)
            .unwrap();
        assert_eq!(tree.get(&key(0)).unwrap().value, 3, "no second migration");
        assert_eq!(tree.get(&key(1)).unwrap().value, 3, "no second migration");
    }
}

/// When no migration step is registered for the stored version, open must
/// return `DbError::SchemaMismatch { kind: MissingStep { from: 1 } }`.
#[test]
fn migration_missing_step_returns_schema_mismatch() {
    let dir = tempdir().unwrap();

    // Open with V1, close.
    {
        let db = Db::open_test(dir.path()).unwrap();
        let _tree = db
            .open_typed_tree::<ItemV1, RapiraCodec, _>(Config::test(), NoHook, &[])
            .unwrap();
        db.close().unwrap();
    }

    // Reopen with V3, no migrations registered → MissingStep { from: 1 }.
    let db = Db::open_test(dir.path()).unwrap();
    let err = unwrap_err(db.open_typed_tree::<ItemV3, RapiraCodec, _>(Config::test(), NoHook, &[]));
    match err {
        DbError::SchemaMismatch {
            kind: SchemaMismatchKind::MissingStep { from },
            ..
        } => {
            assert_eq!(from, 1, "MissingStep should report from=1");
        }
        other => panic!("expected SchemaMismatch::MissingStep, got: {other}"),
    }
}

/// Reopening with a lower VERSION than stored must return
/// `DbError::SchemaMismatch { kind: Downgrade { stored: 3, requested: 2 } }`.
#[test]
fn migration_downgrade_returns_schema_mismatch() {
    let dir = tempdir().unwrap();

    // Open with V3 to persist version=3.
    {
        let db = Db::open_test(dir.path()).unwrap();
        let _tree = db
            .open_typed_tree::<ItemV3, RapiraCodec, _>(Config::test(), NoHook, &[])
            .unwrap();
        db.close().unwrap();
    }

    // Reopen with V2 → Downgrade { stored: 3, requested: 2 }.
    let db = Db::open_test(dir.path()).unwrap();
    let err = unwrap_err(db.open_typed_tree::<ItemV2, RapiraCodec, _>(Config::test(), NoHook, &[]));
    match err {
        DbError::SchemaMismatch {
            kind: SchemaMismatchKind::Downgrade { stored, requested },
            ..
        } => {
            assert_eq!(stored, 3);
            assert_eq!(requested, 2);
        }
        other => panic!("expected SchemaMismatch::Downgrade, got: {other}"),
    }
}

/// Reopening the same VERSION but with a different struct layout (different
/// typ_hash) must return `DbError::SchemaMismatch { kind: TypHash { .. } }`.
#[test]
fn migration_typ_hash_drift_returns_schema_mismatch() {
    let dir = tempdir().unwrap();

    // Open with HashItemA (value: u64), VERSION=1.
    {
        let db = Db::open_test(dir.path()).unwrap();
        let _tree = db
            .open_typed_tree::<HashItemA, RapiraCodec, _>(Config::test(), NoHook, &[])
            .unwrap();
        db.close().unwrap();
    }

    // Reopen with HashItemB (value: u64, extra: u32) — same VERSION=1 but
    // different struct layout → different GetType hash.
    let db = Db::open_test(dir.path()).unwrap();
    let err =
        unwrap_err(db.open_typed_tree::<HashItemB, RapiraCodec, _>(Config::test(), NoHook, &[]));
    match err {
        DbError::SchemaMismatch {
            kind: SchemaMismatchKind::TypHash { stored, expected },
            ..
        } => {
            assert_ne!(stored, expected, "hashes must differ");
        }
        other => panic!("expected SchemaMismatch::TypHash, got: {other}"),
    }
}

/// First open of a fresh directory (no prior CollectionInfo) with any VERSION
/// and an empty migrations list must succeed, and the stored version must
/// equal the type's VERSION constant.
#[test]
fn migration_first_open_no_step_required() {
    let dir = tempdir().unwrap();

    // Open V2 on a fresh dir with no migrations.
    {
        let db = Db::open_test(dir.path()).unwrap();
        let _tree = db
            .open_typed_tree::<ItemV2, RapiraCodec, _>(Config::test(), NoHook, &[])
            .unwrap();
        db.close().unwrap();
    }

    // Verify stored version is 2.
    let db = Db::open_test(dir.path()).unwrap();
    let stored = db.db_info.cloned().collections;
    let info = stored
        .get("mig_test_items")
        .expect("collection info must exist");
    assert_eq!(info.version, 2, "stored version must match V2::VERSION");
}

/// Per-step version commits (resumability): if db.info already records
/// stored.version=2 (the V1→V2 step committed before a crash), only the
/// V2→V3 step must run on the next open — the V1→V2 step must not repeat.
#[test]
fn migration_resumes_from_intermediate_version() {
    let dir = tempdir().unwrap();

    // step_add_10 represents V1→V2: adds 10.
    fn add_ten(_k: &[u8; 8], v: &ItemV3) -> MigrateAction<ItemV3> {
        MigrateAction::Update(ItemV3 {
            value: v.value + 10,
        })
    }
    // step_add_100 represents V2→V3: adds 100.
    fn add_hundred(_k: &[u8; 8], v: &ItemV3) -> MigrateAction<ItemV3> {
        MigrateAction::Update(ItemV3 {
            value: v.value + 100,
        })
    }

    #[allow(clippy::type_complexity)]
    let migrations: &[(u16, fn(&[u8; 8], &ItemV3) -> MigrateAction<ItemV3>)] =
        &[(1, add_ten), (2, add_hundred)];

    // Phase 1: open V1, write one entry (value=1), then patch db.info to
    // version=2 to simulate a crash after the V1→V2 step committed.
    {
        let db = Db::open_test(dir.path()).unwrap();
        let tree = db
            .open_typed_tree::<ItemV1, RapiraCodec, _>(Config::test(), NoHook, &[])
            .unwrap();
        tree.put(&key(0), ItemV1 { value: 1 }).unwrap();
        // Simulate: V1→V2 committed version=2 but V2→V3 did not run.
        db.db_info
            .update(|info| {
                if let Some(ci) = info.collections.get_mut(ItemV1::NAME) {
                    ci.version = 2;
                }
            })
            .expect("test persist");
        db.close().unwrap();
    }

    // Phase 2: open with V3 and both migration steps.
    // Only V2→V3 (+100) must run; V1→V2 (+10) must not repeat.
    // Expected final value: 1 + 100 = 101, NOT 1 + 10 + 100 = 111.
    {
        let db = Db::open_test(dir.path()).unwrap();
        let tree = db
            .open_typed_tree::<ItemV3, RapiraCodec, _>(Config::test(), NoHook, migrations)
            .unwrap();
        assert_eq!(
            tree.get(&key(0)).unwrap().value,
            101,
            "only V2→V3 step should run; expected 1 + 100 = 101"
        );
    }
}