powercut 0.1.1

Prove an acknowledged write survives a power cut or process crash, in cargo test. A database-agnostic crash-injection harness that derives the durable set from the fsyncs that actually happened.
Documentation
//! `powercut` against a store you can read in one sitting: a ~50-line
//! append-only log with no dependencies at all.
//!
//! ```console
//! cargo run --example generic_file_store
//! ```
//!
//! Everything the harness does is visible here, which is the point — no part
//! of the result depends on trusting an engine you did not write.
//!
//! Three cases, and they are the three you will write for your own store:
//!
//! 1. **commit, no fsync** — the write reached the file and no further.
//!    `Lost`, with a teeth control proving the red is about durability.
//! 2. **commit + durable-ack** — one extra `fsync` at the ack. `Survived`.
//! 3. **the read-fork close-tear** — an unrelated *reader* opens the store and
//!    closes it, its close compacts the log, and data that **was** durable
//!    stops being durable. Nothing errored. Nothing logged.

use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};

use powercut::{Checker, Report, Scenario, ScratchDir, SyncJournal, Tier, Verdict, Violation};

// ── The store under test ────────────────────────────────────────────────
// An append-only `key=value` log, folded into a base file by whoever closes
// the store last. That fold is the interesting part: it is the generic shape
// of a WAL checkpoint, an LSM compaction, and SQLite closing its last handle.

struct LogStore {
    log: PathBuf,
    file: File,
}

impl LogStore {
    fn open(dir: &Path) -> std::io::Result<Self> {
        let log = dir.join("store.log");
        let file = OpenOptions::new().create(true).append(true).open(&log)?;
        Ok(Self { log, file })
    }

    fn put(&mut self, key: &str, value: &str) -> std::io::Result<()> {
        writeln!(self.file, "{key}={value}")?;
        self.file.flush() // userspace flush only: this is `commit`, not `fsync`
    }

    fn get(dir: &Path, key: &str) -> Option<String> {
        let mut found = None;
        for path in [dir.join("store.base"), dir.join("store.log")] {
            let Ok(file) = File::open(&path) else {
                continue;
            };
            for line in BufReader::new(file).lines().map_while(Result::ok) {
                if let Some((k, v)) = line.split_once('=') {
                    if k == key {
                        found = Some(v.to_string());
                    }
                }
            }
        }
        found
    }

    /// Fold the log into the base file and unlink the log.
    ///
    /// The bug is the ordering, and it is the single most common durability
    /// bug there is: the directory **is** fsynced, so the unlink is genuinely
    /// durable, while `store.base` is **not**, so the data it just absorbed is
    /// not. Committed data moves out of a file that had a durability warrant
    /// and into one that has none, and every write still returned `Ok`.
    fn compact_on_close(dir: &Path, journal: &SyncJournal) -> std::io::Result<()> {
        let log = dir.join("store.log");
        let base = dir.join("store.base");
        let mut merged = std::fs::read_to_string(&base).unwrap_or_default();
        merged.push_str(&std::fs::read_to_string(&log).unwrap_or_default());
        std::fs::write(&base, merged)?; // ← no fsync of `base`...
        std::fs::remove_file(&log)?;
        journal.sync_dir(dir)?; // ← ...but the unlink is made durable
        Ok(())
    }
}

fn main() {
    let reports = vec![
        commit_without_fsync(),
        durable_ack(),
        read_fork_close_tear(),
    ];
    let expected = [Verdict::Lost, Verdict::Survived, Verdict::Lost];

    for report in &reports {
        println!("{}", report.to_text());
    }
    println!("{}", powercut::to_markdown(&reports));

    // The example is its own test: each case asserts the verdict it exists to
    // demonstrate, so a change in behaviour fails CI rather than scrolling by.
    for (report, expected) in reports.iter().zip(expected) {
        assert_eq!(
            report.verdict(),
            expected,
            "`{}` was expected to be {expected}\n{}",
            report.label(),
            report.to_text()
        );
    }
    println!("all {} cases behaved as documented", reports.len());
}

/// The bug: `put` returns `Ok`, the bytes are in the file, nothing is durable.
fn commit_without_fsync() -> Report {
    let store = ScratchDir::new("generic-no-fsync").unwrap();
    let journal = SyncJournal::complete();
    let dir = store.path().to_path_buf();

    Scenario::new(&dir, journal.clone())
        .label("generic/commit-without-fsync")
        .tier(Tier::PowerCut)
        .ack(|| {
            let mut s = LogStore::open(&dir).unwrap();
            s.put("balance", "100").unwrap();
            "100"
        })
        .unwrap()
        // The teeth: with an explicit flush the same copy and the same checker
        // MUST find the data. If they do not, powercut blames itself.
        .with_teeth_control(|| {
            journal.sync_path(dir.join("store.log"))?;
            journal.sync_dir(&dir)?;
            Ok(())
        })
        .unwrap()
        .check(check_balance)
}

/// The fix: one `fsync` of the log plus one of the directory, at the ack.
fn durable_ack() -> Report {
    let store = ScratchDir::new("generic-durable-ack").unwrap();
    let journal = SyncJournal::complete();
    let dir = store.path().to_path_buf();

    Scenario::new(&dir, journal.clone())
        .label("generic/durable-ack")
        .tier(Tier::PowerCut)
        .ack(|| {
            let mut s = LogStore::open(&dir).unwrap();
            s.put("balance", "100").unwrap();
            journal.sync_all(&s.file, &s.log).unwrap(); // ← delete either line
            journal.sync_dir(&dir).unwrap(); //            ← and this goes red
            "100"
        })
        .unwrap()
        .check(check_balance)
}

/// The flagship: durable data stops being durable because somebody *read*.
fn read_fork_close_tear() -> Report {
    let store = ScratchDir::new("generic-read-fork").unwrap();
    let journal = SyncJournal::complete();
    let dir = store.path().to_path_buf();

    // Phase 1 — write, and make it genuinely durable. This part is correct,
    // and a powercut run right here would come back `Survived`.
    {
        let mut s = LogStore::open(&dir).unwrap();
        s.put("balance", "100").unwrap();
        journal.sync_all(&s.file, &s.log).unwrap();
        journal.sync_dir(&dir).unwrap();
    }

    Scenario::new(&dir, journal.clone())
        .label("generic/read-fork-close-tear")
        .tier(Tier::PowerCut)
        .ack(|| {
            // Phase 2 — a reader opens the store, reads, and closes. Its close
            // compacts. No write, no error, no new fsync of the data.
            let _reader = LogStore::get(&dir, "balance");
            LogStore::compact_on_close(&dir, &journal).unwrap();
            "100"
        })
        .unwrap()
        .with_teeth_control(|| {
            journal.sync_path_if_present(dir.join("store.base"))?;
            journal.sync_dir(&dir)?;
            Ok(())
        })
        .unwrap()
        .check(check_balance)
}

fn check_balance(crash: &powercut::CrashState, acked: &&str) -> Vec<Violation> {
    let mut c = Checker::new();
    c.survives(
        "balance",
        Some((*acked).to_string()),
        LogStore::get(crash.dir(), "balance"),
    );
    c.finish()
}