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.
|
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 |
|
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:
- content truth: bytes written, bytes synced, durable byte prefix, torn/short reads;
- 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.
Why now
BatPak already has a serious crash-recovery gauntlet. The current
SimFs/recovery stack proves a realStoreover 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_diras 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
StoreFscontract sayssync_parent_dirmust not returnOkbefore the freshly-created file's name survives a crash, andStagedFile::persistmust 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:
StoreFshas 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.batpak/bpk-lib/crates/core/src/store/platform/fs.rs
Lines 124 to 155 in 2afd2e4
persist_temp_with_parent_syncdoes the platform thing on Unix: sync temp file, persist/rename, then sync parent directory; non-Unix is explicitly a rename-only platform asymmetry.batpak/bpk-lib/crates/core/src/store/platform/sync.rs
Lines 8 to 17 in 2afd2e4
SimFsalready models dropped handle syncs, durable byte prefixes, crash truncation, op faults on rename/remove/persist, and positioned-read faults.batpak/bpk-lib/crates/core/src/store/sim/fs.rs
Lines 11 to 23 in 2afd2e4
batpak/bpk-lib/crates/core/src/store/sim/recovery_matrix.rs
Lines 40 to 45 in 2afd2e4
batpak/bpk-lib/tools/integrity/src/gate_registry.rs
Lines 199 to 219 in 2afd2e4
batpak/bpk-lib/tools/integrity/src/gate_registry.rs
Lines 20 to 35 in 2afd2e4
batpak/ROADMAP.md
Lines 111 to 138 in 2afd2e4
batpak/ROADMAP.md
Lines 171 to 175 in 2afd2e4
External prior art / receipts
https://www.sqlite.org/testing.html
https://apple.github.io/foundationdb/testing.html
https://tigerbeetle.com/blog/2023-07-06-simulation-testing-for-liveness/
https://jepsen.io/analyses
https://arxiv.org/abs/2003.10554
https://bheisler.github.io/criterion.rs/book/faq.html
Proposal
Add a namespace-aware shadow filesystem model behind or alongside
SimFs.Working name:
The model should track two orthogonal truths:
That means modeling the filesystem as a small logical tree, not just a map of
path -> durable_len.Suggested states per file path:
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:
create_new_file;StagedFile::persist;rename;remove_file;New legality oracle
Extend the recovery classification from:
to include namespace-aware legality checks:
Every reopened state must satisfy:
(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
BenchEvidenceTapeemitted by targeted benches and recovery/perf sentinels:Rules of thumb:
projection_run20× layout cliff, require an evidence tape explaining whether the slow layouts do extra projection/index work or whether the fast layouts short-circuit.O(waiters × targets).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
ShadowFs/NamespaceSimFslayer, orSimFswith explicit namespace durability state.BATPAK_SEEDor equivalent seed input.gate_registry.rsrow with anti-vacuous red fixture coverage. Red fixture must plant at least one impossible namespace outcome and prove the checker rejects it.BenchEvidenceTapeor equivalent receipt exists for at least one roadmap perf cliff, preferablyprojection_runfirst because it has the biggest cheap-win smell.ci-fastor default PR path if cheap enough;Non-goals
BatPak flavor
Current
SimFsproves 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.