Skip to content

GAUNT-NAMESPACE-DURABILITY-SHADOW-FS: prove directory-entry crash semantics, not just durable byte prefixes #177

Description

@heyoub

Why now

BatPak already has a serious crash-recovery gauntlet. The current SimFs/recovery stack proves a real Store over a faulting filesystem can crash, reopen, and satisfy prefix/no-undead/hash-chain legality. The recovery matrix also covers honest disk, lying fsync, and crash-before-fsync boundaries. That is not the gap.

The gap is narrower and nastier: namespace durability. Today the simulation is excellent at file-content durability, but the matrix explicitly models SimFs::sync_parent_dir as always durable: a crash truncates file contents, but never loses or resurrects directory entries. That leaves a SQLite-class power-loss surface under-modeled: create/rename/unlink/persist directory-entry survival after crashes.

This matters because BatPak's own StoreFs contract says sync_parent_dir must not return Ok before the freshly-created file's name survives a crash, and StagedFile::persist must be an atomic publish. If the simulator assumes that namespace truth for free, the gauntlet cannot falsify an embedder backend that loses the name, resurrects an old name, keeps both names, or leaves temp/final entries in an impossible combination.

No open duplicate found in the pre-flight searches: parent directory sync namespace durability, SimFs parent-dir fsync namespace rename remove persist temp crash, directory entry survives crash namespace rename unlink parent-dir sync, VFS shadow durability, GAUNT-NAMESPACE-DURABILITY.

Repo evidence from this scan

Current strength:

  • StoreFs has the right durability contract: sync means durable for both file contents and parent directory names; staged publish must be old-or-new, never torn mixture; rename/remove are crash-sensitive compaction points.
    }
    pub(crate) fn open_file(path: &Path) -> io::Result<File> {
    File::open(path)
    }
    pub(crate) fn read(path: &Path) -> io::Result<Vec<u8>> {
    std::fs::read(path)
    }
    pub(crate) fn read_dir(path: &Path) -> io::Result<ReadDir> {
    std::fs::read_dir(path)
    }
    pub(crate) fn create_dir_all(path: &Path) -> io::Result<()> {
    std::fs::create_dir_all(path)
    }
    pub(crate) fn canonicalize(path: &Path) -> io::Result<PathBuf> {
    std::fs::canonicalize(path)
    }
    pub(crate) fn metadata(path: &Path) -> io::Result<Metadata> {
    std::fs::metadata(path)
    }
    pub(crate) fn symlink_metadata(path: &Path) -> io::Result<Metadata> {
    std::fs::symlink_metadata(path)
    }
    pub(crate) fn remove_file(path: &Path) -> io::Result<()> {
    std::fs::remove_file(path)
  • persist_temp_with_parent_sync does the platform thing on Unix: sync temp file, persist/rename, then sync parent directory; non-Unix is explicitly a rename-only platform asymmetry.
    /// The tempfile's contents must already be fsynced by the caller (this
    /// function only handles the rename + directory-entry durability). On
    /// unix the parent directory is opened and `sync_all`-ed after the
    /// rename, so a crash immediately after this returns cannot lose the
    /// directory entry that points at the new inode. On non-unix targets
    /// `File::sync_all` on a directory is not meaningful, so the parent
    /// fsync is skipped — the asymmetry is platform-level, not a shortcut:
    /// windows POSIX semantics for directory fsync simply do not exist.
    pub(crate) fn persist_temp_with_parent_sync(
    named_temp: tempfile::NamedTempFile,
  • SimFs already models dropped handle syncs, durable byte prefixes, crash truncation, op faults on rename/remove/persist, and positioned-read faults.
    //! **honored**: the file's current inner length becomes its durable
    //! length. Under the seed's schedule a sync is **dropped**: the call still
    //! returns `Ok` to the store (a silently-lying disk), but the durable
    //! length is NOT advanced, so the most recent bytes are lost on the next
    //! crash.
    //! * [`SimFs::crash`] truncates every tracked file to its last durable
    //! length, discarding the write-but-unsynced (and sync-dropped) tail.
    //! This models power loss losing the OS page-cache tail. (Truncation
    //! reaches through the platform seam and therefore requires a
    //! real-file-backed inner today; the fault schedules themselves are
    //! backend-agnostic.)
    //!
    //! Reopening a real [`crate::store::Store`] over the same data directory after
  • The B3 recovery matrix explicitly states the current terminal parent-dir model: parent-dir sync is treated as always durable; pure dropped parent-dir sync is not independently modeled.
    //! created file), so a pure "parent-dir sync dropped" mode is not independently
    //! modeled. The [`Boundary::SegmentRotationCreate`] cell exercises the
    //! new-segment-create + dir-sync window via the injector instead. Witnessed by
    //! `recovery_matrix.rs::sim_parent_dir_sync_fail_closed_model_honest_deferral`.
    use super::fs::SimFs;
  • The gate registry already gives recovery-oracle and linearizability real blocking authority with red fixtures. So this issue should extend the model, not reinvent the existing recovery oracle.
    // backend, driven through the real append/sync API, crashed at the
    // durability boundary, and reopened; the oracle fails closed on any
    // illegal recovered state (lost-after-sync commit, undead event, broken
    // hash chain, non-canonical reopen) or nondeterminism. Under
    // `gauntlet_red_fixture` the test asserts the (illegal) lost-after-sync
    // outcome, so its red half fails — proving the oracle bites. ---
    Gate {
    slug: "dst-recovery",
    red_fixture_test: Some(
    "crates/core/tests/dst_recovery.rs::dst_recovery_is_legal_and_deterministic",
    ),
    red_fixture_kind: Some(RedFixtureKind::ProductionFlip),
    has_blocking_authority: true,
    },
    // --- Phase-B3 recovery-oracle matrix (blocking, qualified ProductionFlip).
    // B2's dst-recovery ran the legality oracle under ONE fault profile
    // (honest disk). B3 generalizes it across the FULL hostile-fs matrix SimFs
    // can model over the real Store: honest-disk crash, lying-disk fsync-drop,
    // and crash-before-fsync at each durability boundary (single-append frame,
    // batch-commit marker, post-fsync-before-publish, segment-rotation create).
    // Every cell must recover EXACTLY one of {CommittedPrefix | RolledBack |

    //! Err branch); the registry additionally requires the test body to contain an
    //! explicit failure-expecting assertion (`is_err`/`expect_err`/`Err(`/
    //! `should_panic`/…), so a budget-less "consistent OR typed error" tautology
    //! cannot qualify.
    //! - [`RedFixtureKind::ProductionFlip`]: a test gated by
    //! `#[cfg(gauntlet_red_fixture)]` — green on correct production, RED when the
    //! cfg flips production (or the test's expectation) to the broken variant. Its
    //! red half is PROVEN in automation by the `gauntlet-red-fixtures-bite` CI lane
    //! (and `cargo xtask prove-gates-bite`), which builds with the cfg and asserts
    //! the fixture FAILS. The registry requires the file to contain a
    //! `gauntlet_red_fixture` branch.
    //!
    //! A gate that genuinely blocks today but has no qualified RED fixture yet is
    //! recorded with `has_blocking_authority: false` and listed in
    //! [`UNQUALIFIED_BLOCKING_GATES`] as an explicit finding — we do NOT fabricate a
    //! fixture to launder authority it has not earned.
  • The roadmap already calls out perf investigation cliffs and 1.0 exit criteria requiring heavy matrix, fuzz, chaos, loom, and Windows to be green. This is the right moment to harden durability semantics before the API freezes.

    batpak/ROADMAP.md

    Lines 111 to 138 in 2afd2e4

    Surfaced by a full local Criterion soak of the neutral surface on weak hardware
    (i5-1035G1, 15 W, thermal-limited). Absolutes are a floor; the ratios/shapes are
    the signal. The cloud `perf.yml` reference baseline is the apples-to-apples
    comparison and should be captured alongside these. None are 0.10.0 blockers.
    - [ ] **`projection_run` 20× layout cliff.** `evidence/projection_run` on the
    same operation: `entity-local` ≈ 46 µs and `all` ≈ 42 µs, but `aos` ≈ 857 µs,
    `scan` ≈ 847 µs, `tiled` ≈ 902 µs — a ~20× gap. `read_walk/query_with_report`
    is uniform (~600 µs) across the *same* layouts, so this is projection-path
    specific. Investigate whether `aos`/`scan`/`tiled` do redundant work in the
    projection run path or `entity-local`/`all` hit a short-circuit — likely a
    large free win for the slow layouts. (`benches/evidence_reports.rs`,
    `src/store/projection/`, `src/store/index/columnar/`.)
    - [ ] **Frontier wake superlinear at 512 spread waiters.**
    `frontier_waiter_wake_all` scales fine to 128, then `spread-targets/512` =
    ~626 ms (durable) / ~378 ms (visible) vs `same-target/512` = ~53 ms — a ~12×
    cliff when many waiters are spread across many distinct targets. Fine for
    realistic fan-out; a wall for 500+ waiters on distinct targets. Confirm the
    wake set isn't doing an O(waiters × targets) sweep. (`benches/frontier_waiters.rs`,
    `src/store/write/` frontier/waiter wake path.)
    - [ ] **Non-monotonic 100k cold-start dip.** `cold_start/reopen_open_only`
    per-event throughput: ~220 K/s @10k, **~67 K/s @100k**, ~222 K/s @1M — the
    100k corpus is per-event slower than *both* neighbors, breaking an otherwise
    clean linear curve. Confirm it's a corpus-gen artifact vs. a real
    segment-count / cache threshold. (`benches/cold_start.rs`,
    `src/store/cold_start/`.)
    ## 3. 1.0.0 track — API stabilization & ergonomics

    batpak/ROADMAP.md

    Lines 171 to 175 in 2afd2e4

    published crates; heavy matrix (24-shard mutation, fuzz, chaos, loom,
    Windows) green on the release candidate; docs current (per-change +
    repo-wide audit); CHANGELOG migration notes for every break since 0.9.0.
    ## 3b. CI cost policy (durable — the $422 lesson, 2026-07-02)

External prior art / receipts

  • SQLite's testing doctrine is the closest model for “small, fast, reliable” storage: independent harnesses, OOM tests, I/O error tests, crash/power-loss tests, fuzzing, malformed database tests, branch coverage, mutation testing, and release checklists. Especially relevant: SQLite crash tests use a special VFS and simulate power loss by reordering/corrupting unsynchronized writes, then reopening and running integrity checks.
    https://www.sqlite.org/testing.html
  • FoundationDB leans hard on deterministic simulation to find failures before the real world; it models machines, disks, networks, failures, degraded performance, reboots, and runs daily performance comparisons. The key transferable idea is not “build FDB”; it is: make failures replayable and make performance evidence comparable across time.
    https://apple.github.io/foundationdb/testing.html
  • TigerBeetle's VOPR split safety and liveness modes. For BatPak, the analogue is content-durability safety vs namespace-durability safety vs progress/liveness under partially failed storage seams.
    https://tigerbeetle.com/blog/2023-07-06-simulation-testing-for-liveness/
  • Jepsen's testing niche is opaque-box/generative tests under failure modes, with histories checked against a model. BatPak already has model-checked histories; this issue pushes the storage model closer to what a real filesystem can do under strain.
    https://jepsen.io/analyses
  • Elle shows the value of deriving dependency/anomaly explanations from observed histories rather than relying on local assertions alone. BatPak's recovery classifications should get the same “explain the anomaly” ergonomics for namespace failures.
    https://arxiv.org/abs/2003.10554
  • Criterion's own FAQ warns that cloud CI introduces enough benchmark noise that wall-clock results can look changed even when the code is not; it suggests instruction/memory-access style measurements for virtualized environments. This supports using counts/receipts as PR gates and wall-time as trend evidence, not gospel.
    https://bheisler.github.io/criterion.rs/book/faq.html

Proposal

Add a namespace-aware shadow filesystem model behind or alongside SimFs.

Working name:

GAUNT-NAMESPACE-DURABILITY-SHADOW-FS

The model should track two orthogonal truths:

  1. content truth: bytes written, bytes synced, durable byte prefix, torn/short reads;
  2. namespace truth: directory entries created, renamed, removed, parent-dir synced, and which entries survive a crash.

That means modeling the filesystem as a small logical tree, not just a map of path -> durable_len.

Suggested states per file path:

Absent
TempOnly { temp_path, content_durable_len, name_durable: bool }
FinalOld { old_inode }
FinalNewUnsyncedName { new_inode }
FinalNewDurableName { new_inode }
BothImpossibleTrap { old_inode, new_inode }        // must never be observable after legal recovery
LostNameButContentExists { inode }                // backend bug shape
ResurrectedName { inode }                         // backend bug shape

Do not overfit the enum names. The important thing is that the oracle can express impossible namespace outcomes instead of silently treating names as durable.

Failure families to model

Add fault schedules for namespace edges, independent from content sync drops:

  • parent-dir sync dropped after create_new_file;
  • parent-dir sync dropped after StagedFile::persist;
  • parent-dir sync dropped after rename;
  • parent-dir sync dropped after remove_file;
  • crash after temp-file content sync but before final name durability;
  • crash after final name appears but before parent directory sync;
  • crash during compaction swap/rollback with old/new segment names;
  • crash during metadata artifact publish: mmap-index, checkpoint, idempotency sidecar, visibility ranges, keyset;
  • virtual backend mismatch: MemFs-style backend must not get a stronger namespace contract than RealFs unless it proves it.

New legality oracle

Extend the recovery classification from:

CommittedPrefix | RolledBack | CanonicalRefusal

to include namespace-aware legality checks:

CommittedPrefix { namespace: LegalNamespace }
RolledBack { namespace: LegalNamespace }
CanonicalRefusal { error: typed }
IllegalNamespace { explanation, path, op, seed }

Every reopened state must satisfy:

  • no invented/undead user events;
  • no lost acknowledged-durable commit on honest disk;
  • intact hash chain;
  • no impossible directory-entry state;
  • no stale temp/final pair that changes cold-start meaning;
  • no silent rebuild from a namespace state that should be a typed refusal;
  • deterministic replay by (seed, op_plan, namespace_fault_schedule).

Bench/perf side quest: make the non-physics-bounded benches scream honestly

Use this same shadow-fs work to improve perf receipts, not just correctness.

Add an optional BenchEvidenceTape emitted by targeted benches and recovery/perf sentinels:

bench: projection_run/aos
seed: 123
input_shape:
  events: 100000
  layout: aos
storage_work:
  file_opens: 12
  reads: 100003
  bytes_read: 8388608
  writes: 3
  bytes_written: 4096
  fsyncs: 1
  parent_dir_syncs: 0
  renames: 0
  removes: 0
cpu_work:
  allocations: 41
  optional_perf_stat:
    instructions: ...
    cycles: ...
    cache_misses: ...
classification:
  physics_bounded: false
  suspected_extra_work: true

Rules of thumb:

  • PR gates should prefer work-unit budgets over cloud wall-clock budgets: allocations, file ops, bytes, fsync count, parent-dir sync count, instruction/cache counts where available.
  • Criterion wall time remains useful for local/dev/cloud trend evidence, but cloud CI should not make final claims from wall time alone.
  • For the roadmap's projection_run 20× layout cliff, require an evidence tape explaining whether the slow layouts do extra projection/index work or whether the fast layouts short-circuit.
  • For the frontier 512 spread-target cliff, require a work-count proof of whether the wake path is accidentally O(waiters × targets).
  • For the 100k cold-start dip, require a corpus-shape receipt: segment count, footer count, mmap/checkpoint/scan choice, bytes scanned, file opens, allocations, and recovered event count.

This is the “cure the lower non-physics-bounded benches” rule: do not merely optimize until the stopwatch smiles. First prove where the work went, then cut the extra work. Scalpel before chainsaw.

Acceptance criteria

  • A new namespace-aware model exists either as:
    • a new ShadowFs/NamespaceSimFs layer, or
    • an extension of SimFs with explicit namespace durability state.
  • The model can replay a failing seed deterministically using BATPAK_SEED or equivalent seed input.
  • Parent-dir sync is no longer globally assumed always-durable in the B3/B4 durability proof path; where it remains simplified, the simplification is explicitly scoped and guarded by a test that proves the stronger namespace model covers the omitted behavior elsewhere.
  • New recovery tests cover at least:
    • dropped parent-dir sync after staged publish;
    • dropped parent-dir sync after segment create;
    • dropped parent-dir sync after compaction rename;
    • dropped parent-dir sync after remove/reclaim;
    • stale temp plus final artifact after crash;
    • virtual backend namespace mismatch;
    • typed refusal instead of silent rebuild when namespace state is unprovable.
  • The new gate has a gate_registry.rs row with anti-vacuous red fixture coverage. Red fixture must plant at least one impossible namespace outcome and prove the checker rejects it.
  • Existing recovery oracle remains intact; this issue adds namespace evidence rather than weakening content-durability evidence.
  • BenchEvidenceTape or equivalent receipt exists for at least one roadmap perf cliff, preferably projection_run first because it has the biggest cheap-win smell.
  • CI placement is cost-aware:
    • small namespace seed set in ci-fast or default PR path if cheap enough;
    • larger seed/fault matrix behind chaos/heavy profile;
    • no new always-on whale job.

Non-goals

  • Do not replace the existing recovery oracle.
  • Do not claim cross-platform POSIX directory fsync semantics on Windows/non-Unix; model the asymmetry honestly.
  • Do not turn benchmark gates into fragile wall-clock astrology.
  • Do not hand-wave “SQLite grade” as vibes. The standard here is replayable failure evidence, anti-vacuous gates, typed refusals, and receipts that explain both correctness and performance.

BatPak flavor

Current SimFs proves the bytes. This issue makes the names testify too. The crime scene is not just the corpse length; it is also the door locks, footprints, and whether the supposedly dead temp file is still smoking in the alley.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions