haematite 0.7.0

Content-addressed, branchable, actor-native storage engine
Documentation
//! FENCE-CHAIN r8 R4a — dual-polarity walls for the metadata / refs / startup
//! fence sites: L4-persist (`SnapshotError` via BOTH `SnapshotRegistry` and
//! `CommitLog`), L4/L5-refstore (`BranchRefError`), and L5-startup
//! (`DatabaseError::DirectoryCreate`). Same two polarities as `wall_tests`:
//! FAILPOINT → typed refusal; LOSSY + CUT → observable loss, with a positive
//! control.

use std::error::Error;

use super::journal;
use crate::branch::{
    BranchKind, BranchRefError, BranchRefRecord, BranchRefStore, BranchShardRef, CommitLog,
    SnapshotError, SnapshotRegistry,
};
use crate::db::{Database, DatabaseConfig, DatabaseError};
use crate::tree::Hash;

type TestResult = Result<(), Box<dyn Error>>;

fn config(data_dir: &std::path::Path) -> DatabaseConfig {
    DatabaseConfig {
        data_dir: data_dir.to_path_buf(),
        shard_count: 1,
        distributed: None,
        executor_threads: None,
    }
}

fn record(name: &str) -> BranchRefRecord {
    BranchRefRecord {
        name: name.to_owned(),
        created: 1,
        kind: BranchKind::Work,
        namespace_lineage: None,
        seq: 0,
        timestamp: 1,
        shards: vec![BranchShardRef {
            shard_id: 0,
            fork_anchor: Hash::from_bytes([7; 32]),
            head: Hash::from_bytes([7; 32]),
        }],
        parents: Vec::new(),
    }
}

// --- L4-persist: SnapshotRegistry AND CommitLog → SnapshotError ---------------

/// L4-persist FAILPOINT polarity: an injected containing-directory fence failure
/// refuses BOTH `SnapshotRegistry::open` and `CommitLog::open` with
/// `SnapshotError` (naming both sites explicitly, per the census).
#[test]
fn wall_l4_persist_failpoint_refuses_both_opens() -> TestResult {
    let _guard = journal::test_guard();

    let temp = tempfile::tempdir()?;
    journal::arm_failpoint();
    let registry = SnapshotRegistry::open(temp.path().join("fresh-reg").join("registry.bin"));
    assert!(
        matches!(registry, Err(SnapshotError::Io(_))),
        "SnapshotRegistry::open must refuse on a containing-dir fence failure, got {registry:?}"
    );

    let temp = tempfile::tempdir()?;
    journal::arm_failpoint();
    let log = CommitLog::open(temp.path().join("fresh-log").join("commit.log"));
    assert!(
        matches!(log, Err(SnapshotError::Io(_))),
        "CommitLog::open must refuse on a containing-dir fence failure, got {log:?}"
    );
    Ok(())
}

/// L4-persist LOSSY + CUT polarity: a suppressed containing-dir fence leaves the
/// directory entry pending; the CUT rewinds it and a durable name is lost. The
/// positive control shows a real fence preserves it.
#[test]
fn wall_l4_persist_lossy_cut_loses_named_snapshot() -> TestResult {
    let _guard = journal::test_guard();

    // Positive control.
    let temp = tempfile::tempdir()?;
    let path = temp.path().join("meta").join("registry.bin");
    let mut registry = SnapshotRegistry::open(&path)?;
    registry.name_at("live", Hash::from_bytes([1; 32]), 5)?;
    assert_eq!(
        journal::pending_len(),
        0,
        "a real fence leaves nothing pending"
    );
    journal::cut()?;
    let reopened = SnapshotRegistry::open(&path)?;
    assert_eq!(reopened.get("live"), Some(Hash::from_bytes([1; 32])));

    // Falsifier: fence patched away.
    let temp = tempfile::tempdir()?;
    let path = temp.path().join("meta").join("registry.bin");
    journal::set_lossy(true);
    let mut registry = SnapshotRegistry::open(&path)?;
    registry.name_at("doomed", Hash::from_bytes([2; 32]), 5)?;
    journal::set_lossy(false);
    assert!(
        journal::pending_len() >= 1,
        "unfenced metadata must remain pending"
    );
    journal::cut()?;
    let reopened = SnapshotRegistry::open(&path)?;
    assert_eq!(
        reopened.get("doomed"),
        None,
        "the CUT must rewind the unfenced containing dir, losing the named snapshot"
    );
    Ok(())
}

// --- L4/L5-refstore: BranchRefError -------------------------------------------

/// L4/L5-refstore FAILPOINT polarity: an injected refs-dir fence failure refuses
/// `BranchRefStore::open`; an injected entry-fence failure refuses a record
/// install. The record-install site covers BOTH native install sites named by the
/// domain seat — `write_atomic_noclobber_unfenced` (create) and, via advance,
/// `write_atomic_unfenced` (cas-replace) — each fenced by
/// `entry_fence_with_source`.
#[test]
fn wall_l4l5_refstore_failpoint_refuses() -> TestResult {
    let _guard = journal::test_guard();

    // Refs-dir open fence.
    let temp = tempfile::tempdir()?;
    journal::arm_failpoint();
    let opened = BranchRefStore::open(temp.path().join("fresh-refs"));
    assert!(
        matches!(opened, Err(BranchRefError::Io(_))),
        "BranchRefStore::open must refuse on a refs-dir fence failure, got {opened:?}"
    );

    // Create-install entry fence (write_atomic_noclobber_unfenced site).
    let temp = tempfile::tempdir()?;
    let mut store = BranchRefStore::open(temp.path().join("refs"))?;
    journal::arm_failpoint();
    let created = store.create(record("main"));
    assert!(
        created.is_err(),
        "a create whose entry fence fails must refuse (create-install site)"
    );

    // Cas-replace entry fence (write_atomic_unfenced site) — a fresh store so the
    // record exists to advance.
    let temp = tempfile::tempdir()?;
    let mut store = BranchRefStore::open(temp.path().join("refs"))?;
    store.create(record("main"))?;
    journal::arm_failpoint();
    let advanced = store.advance(
        "main",
        1,
        0,
        &[(0, Hash::from_bytes([9; 32]))],
        Vec::new(),
        2,
    );
    assert!(
        advanced.is_err(),
        "an advance whose entry fence fails must refuse (cas-replace install site)"
    );
    Ok(())
}

/// L4/L5-refstore LOSSY + CUT polarity: a suppressed entry fence on a create
/// leaves the record file pending; the CUT rewinds it and a cold reopen sees no
/// record. Positive control: a real fence preserves the record.
#[test]
fn wall_l4l5_refstore_lossy_cut_loses_record() -> TestResult {
    let _guard = journal::test_guard();

    // Positive control.
    let temp = tempfile::tempdir()?;
    let refs = temp.path().join("refs");
    let mut store = BranchRefStore::open(&refs)?;
    store.create(record("keep"))?;
    assert_eq!(
        journal::pending_len(),
        0,
        "a real entry fence leaves nothing pending"
    );
    journal::cut()?;
    let reopened = BranchRefStore::open(&refs)?;
    assert!(
        reopened.get("keep").is_some(),
        "a fenced record survives the CUT"
    );

    // Falsifier.
    let temp = tempfile::tempdir()?;
    let refs = temp.path().join("refs");
    let mut store = BranchRefStore::open(&refs)?;
    journal::reset(); // isolate from the open-time refs-dir fence
    journal::set_lossy(true);
    store.create(record("lost"))?;
    journal::set_lossy(false);
    assert!(
        journal::pending_len() >= 1,
        "an unfenced record must remain pending"
    );
    journal::cut()?;
    let reopened = BranchRefStore::open(&refs)?;
    assert!(
        reopened.get("lost").is_none(),
        "the CUT must rewind the unfenced record; a cold reopen sees no branch"
    );
    Ok(())
}

// --- L5-startup: DatabaseError::DirectoryCreate -------------------------------

/// L5-startup FAILPOINT polarity: an injected `data_dir` fence failure at
/// `Database::create` refuses with `DatabaseError::DirectoryCreate` before any
/// durable claim (no `config.json` yet).
#[test]
fn wall_l5_startup_failpoint_refuses_create() -> TestResult {
    let _guard = journal::test_guard();
    let temp = tempfile::tempdir()?;
    let data_dir = temp.path().join("db");

    journal::arm_failpoint();
    let created = Database::create(config(&data_dir));

    assert!(
        matches!(created, Err(DatabaseError::DirectoryCreate(_))),
        "an injected data_dir fence failure must refuse with DirectoryCreate, got {created:?}"
    );
    assert!(
        !data_dir.join("config.json").exists(),
        "a refused create must not have written a durable config.json"
    );
    Ok(())
}

/// L5-startup LOSSY + CUT polarity: a suppressed `data_dir` fence at create leaves
/// the `data_dir` entry pending; the CUT rewinds the whole directory, so a cold
/// open cannot find the database. Positive control: a real fence lets the reopen
/// succeed.
#[test]
fn wall_l5_startup_lossy_cut_loses_data_dir() -> TestResult {
    let _guard = journal::test_guard();

    // Positive control: real fence, cold reopen succeeds.
    let temp = tempfile::tempdir()?;
    let data_dir = temp.path().join("db");
    let database = Database::create(config(&data_dir))?;
    drop(database);
    journal::reset(); // isolate the CUT to the create's data_dir entry below
    journal::cut()?;
    assert!(
        data_dir.join("config.json").exists(),
        "a fenced data_dir survives the CUT"
    );
    drop(Database::open(&data_dir)?);

    // Falsifier: fence patched away at create, CUT rewinds the data_dir entry.
    let temp = tempfile::tempdir()?;
    let data_dir = temp.path().join("db");
    journal::set_lossy(true);
    let database = Database::create(config(&data_dir))?;
    journal::set_lossy(false);
    drop(database);
    assert!(
        journal::pending_len() >= 1,
        "an unfenced data_dir must remain pending"
    );
    journal::cut()?;
    assert!(
        !data_dir.exists(),
        "the CUT must rewind the unfenced data_dir entry; the database is unreachable"
    );
    Ok(())
}