Durability & Consistency
This document describes the durability and consistency guarantees provided by ZeroFS, including write atomicity, operation ordering, and crash recovery semantics.
Overview
ZeroFS implements a transactional storage model on object storage. File contents live as compressed, encrypted frames inside immutable segment objects written directly to the object store. Metadata (inodes, directory entries, and one 32-byte FrameLoc pointer per 32 KiB extent) lives in an LSM-tree database. This architecture provides three properties:
- Write Atomicity: All filesystem operations are atomic at the transaction level
- Strict Ordering: Operations are totally ordered and this order is preserved across crashes
- Recovery-Free Startup: No filesystem check or repair is required after unexpected termination
Write Atomicity
Each filesystem operation executes within a single database transaction. A transaction bundles all related modifications into an atomic unit that either commits entirely or has no effect.
Transaction Scope
A write operation includes the following modifications within a single transaction:
- Extent pointers: one 32-byte
FrameLocper 32 KiB extent - Per-segment live-byte counter deltas
- Inode metadata (size, timestamps, mode)
- Directory entry updates
- Global statistics counters
The extent bytes themselves are compressed, encrypted, and appended to the in-RAM open segment buffer as the transaction is staged. No object-store PUT occurs on the write path; the transaction commits only pointers and metadata.
The database commits these changes using a WriteBatch, which provides all-or-nothing semantics. Partial writes cannot occur.
Failure Modes
| Event | Outcome |
|---|---|
| Crash before commit | Transaction discarded, no changes visible |
| Crash during commit | Transaction discarded, no changes visible |
| Crash after commit | All changes visible |
The intermediate state where some but not all modifications are visible is not possible.
Transaction Structure
// Create transaction
let mut txn = db.new_transaction();
// Seal extents into the open segment buffer,
// stage FrameLoc pointers in the transaction
extent_store.write(&mut txn, id, offset, data, old_size);
inode.size = new_size;
inode.mtime = now;
inode_store.save(&mut txn, id, &inode);
directory_store.add(&mut txn, ...);
// Atomic commit via the single-writer coalescer
write_coordinator.commit(txn).await;
Operation Ordering
ZeroFS provides a total order over all write operations. This order is preserved exactly across process termination and restart.
Ordering Guarantee
All write transactions are submitted through a single commit coalescer: each operation sends its transaction to a dedicated worker task that drains the queue, merges pending transactions into one WriteBatch, and issues a single atomic write:
// caller
write_coordinator.commit(txn).await;
// worker (single task, FIFO drain)
let mut batch = vec![rx.recv().await];
while let Ok(m) = rx.try_recv() { batch.push(m); }
let mut merged = WriteBatch::new();
for (txn, _) in &batch { txn.apply_to(&mut merged); }
db.write(merged).await;
Per-inode locks (covering files and directories) serialize operations that observe each other's state; global statistics and per-segment counters are folded by the single commit worker itself, which is their sole writer. If operation A completes before operation B begins, B's transaction is sent strictly after A's commit returned, and therefore lands in a strictly later batch. The visibility of B implies the visibility of A.
Formal Property
For operations A and B where A completes before B starts:
visible(B) → visible(A)
The contrapositive also holds: if A is not visible, B cannot be visible.
Comparison with Other Filesystems
Many filesystems permit write reordering for performance optimization:
- ext4: May reorder writes within journal transactions
- XFS: Delayed allocation can reorder data relative to metadata
- Hardware: SSDs and disk controllers may reorder writes internally
Such reordering can result in states where a later write is visible while an earlier write is not. Applications that depend on ordering semantics (databases, logging systems, configuration management) may observe inconsistent state after recovery.
ZeroFS does not reorder writes. The single-writer commit path enforces a total order over batches that survives crashes.
Crash Recovery
ZeroFS requires no recovery procedure after unexpected termination.
Recovery-Free Design
The underlying storage model has several properties that eliminate the need for crash recovery:
Atomic Commits: WriteBatch operations are atomic at the database level. A transaction either appears in full or not at all.
Immutable Storage: SST files and segment objects written to object storage are immutable. Once uploaded, they cannot be corrupted by subsequent operations. Each segment frame carries its own authenticated-encryption tag (XChaCha20-Poly1305); the segment footer carries a keyless CRC32C that detects torn writes.
Self-Describing State: The database manifest describes the complete set of valid SST files. On startup, ZeroFS reads the manifest and has immediate access to the latest consistent state.
Seal-Before-Manifest Ordering: A flush uploads the open segment before the metadata flush that references it. A durable manifest never points at a missing segment object. A crash between the two steps leaves only an orphaned segment, later reclaimed by segment GC, never a dangling pointer.
No Write-Ahead Log: The WAL is permanently disabled and not configurable. A WAL flush is not gated by the seal barrier: it could make a FrameLoc pointer durable while the segment it points at is still the un-uploaded in-RAM buffer. With the WAL off, the barrier-gated flush is the only path that makes metadata durable, and startup has no log to replay.
Startup Procedure
After any termination (graceful or unexpected):
- Read manifest from object storage
- Load database state from referenced SST files
- Resume operation
No scanning, replay, or repair is performed. The time to resume operation is independent of filesystem size.
Durability Semantics
ZeroFS follows standard POSIX durability semantics. Write operations are buffered in memory and persisted to durable storage upon explicit synchronization.
Buffering Model
File data flows through the following stages:
- Application Buffer: User-space buffers managed by the application
- Page Cache: OS-managed in-memory buffers
- Open Segment Buffer: In-RAM buffer of compressed, encrypted frames
- Segment Objects: Immutable objects in object storage
Metadata (inodes, directory entries, FrameLoc pointers) flows through the LSM memtable into immutable SST files. The open segment uploads in the background once it reaches 256 MiB; at most 4 uploads run concurrently.
A flush is two-phase under a single barrier lock: seal and upload the open segment, then flush the memtable. If the seal fails, the metadata flush is aborted. Flushes occur when:
- A client requests durability (
fsync(), NFS COMMIT, NBD flush) - The periodic flush interval elapses (
flush_interval_secs, default 30 s, minimum 5 s) sync_writes = truecompletes a commit batch- A segment GC pass begins
- The process terminates gracefully
There is no memtable-capacity flush trigger; the memtable drains only at the barrier-gated flush.
POSIX Compliance
This behavior conforms to POSIX semantics, which specify that write() transfers data to system buffers while fsync() ensures data reaches stable storage. All major filesystems (ext4, XFS, APFS, ZFS...) implement this model.
Synchronization API
// POSIX interface
write(fd, data, len); // Buffer in memory
fsync(fd); // Persist to storage
# Python
f.write(data)
f.flush()
os.fsync(f.fileno())
// Go
f.Write(data)
f.Sync()
// Rust
file.write_all(data)?;
file.sync_all()?;
Applications requiring immediate durability must call the appropriate synchronization function.
Verified fsync
A successful fsync is verified: it means every write the descriptor acknowledged before it is durable on object storage. Acknowledged file data lives in the in-RAM open segment buffer, and its metadata in the memtable, until the next flush, so a crash before that flush loses them. ZeroFS does not let that loss pass silently. A fsync issued after a restart, over writes made before it, returns a stale-handle error (ESTALE) rather than a false success.
Each acknowledged write is tagged with a durability lineage token naming the current unbroken durable lineage. A restart reopens the database and, with no way to prove it inherited the un-flushed buffers, regenerates the token. A fsync presents the token its writes were made under; if the database has since regenerated, the tokens differ and the fsync fails. The first fsync after a write is the durability boundary, so a failure reports real loss: redo the writes and fsync again.
This holds on a single node and under replication alike. With a replicated standby a clean failover can prove it inherited the writes (the standby receives un-flushed extent frames over replication and materializes their segments at takeover) and keeps the token, so the fsync succeeds transparently; the failover cases are diagrammed in High availability. The check is per descriptor and matches POSIX: it covers the whole file across every open handle, speaks for no other file, and verifies data, metadata, directory entries, and rename.
Eliminating the Durability Window
By default, writes between fsync calls live in the open segment buffer and memtable and become durable only when the next periodic flush or explicit fsync persists them. Committed-but-unflushed file data is bounded by the 256 MiB open buffer plus up to 4 × 256 MiB of in-flight segment uploads. For workloads that need every write durable on return, set sync_writes = true in the [lsm] section.
When enabled, the commit coordinator forces a flush after every coalesced write batch before returning success to the caller. Concurrent writes still merge into a single batch and a single flush.
This flag does not change POSIX semantics. With sync_writes = false (the default), explicit fsync from clients is always honored and waits for durable persistence, the same behavior all major filesystems implement. The flag only governs what happens to writes between fsync calls.
The trade-off is per-operation latency. The WAL is permanently off, so each batch waits for the full durability barrier: the open segment is sealed and uploaded, then the memtable flushes to an L0 SST. No cheaper WAL-append path exists. This is expensive for chatty workloads.
[lsm]
sync_writes = true
Protocol Considerations
The durability guarantees available to applications depend on the access protocol.
9P Protocol
The 9P protocol provides direct mapping of POSIX synchronization semantics:
fsync()issues a durability-verified flush (Tfsyncdur, a ZeroFS 9P extension negotiated byzerofs mountand the client libraries)- ZeroFS flushes all buffered data to object storage, then verifies the writes' lineage; the call returns success only when durability is confirmed, otherwise
ESTALE(see Verified fsync) - Plain 9P2000.L clients (e.g. the Linux kernel v9fs client) issue
Tfsync: a durable but unverified flush. Withignore_fsync, both standard and durability-verified fsync requests return without forcing a flush; selecting that option explicitly opts out of the guarantee.
NFS Protocol
NFS client implementations do not reliably invoke the COMMIT operation:
- Clients typically report writes as stable without issuing COMMIT
- ZeroFS accepts this to avoid per-write latency penalties
- Effective durability depends on client behavior
For workloads requiring predictable durability semantics, the 9P protocol is recommended.
Verification Through Crash Testing
ZeroFS verifies its consistency guarantees through systematic crash simulation using failpoints injected throughout the data path.
Failpoint Coverage
Failpoints are placed at critical points within each filesystem operation, allowing tests to simulate crashes at any stage:
| Operation | Failpoints |
|---|---|
| write | after extent write, after inode update, after commit |
| fallocate | after extent edits, after inode update, after commit |
| create | after inode allocation, after directory entry, after commit |
| remove | after inode delete, after tombstone, after directory unlink, after commit |
| rename | after target delete, after source unlink, after new entry, after commit |
| mkdir | after inode allocation, after directory entry, after commit |
| truncate | after extent deletion, after inode update, after commit |
| link | after directory entry, after inode update, after commit |
| symlink | after inode allocation, after directory entry, after commit |
| rmdir | after inode delete, after directory cleanup |
| gc | after extent delete, after tombstone update |
| flush | after segment seal, before manifest flush |
| segment compaction | after packed-segment seal, before repoint; between per-inode repoints |
| segment reclaim | after barrier, before scan; after verify, before delete; after segment delete, before counter drop |
| seal | forced open-segment seal error |
Fallocate exposes these boundaries as fallocate_after_extents, fallocate_after_inode, and fallocate_after_commit. Its focused atomicity test verifies that aborting at either pre-commit point discards the staged extent and inode changes, while aborting after commit leaves the complete range operation visible.
The crash-harness failpoints abort the operation at the injection point; the filesystem instance is torn down (discarding all in-memory state) and restarted from object storage, simulating a crash at that exact point. The seal failpoint injects an error return instead, verifying that a failed seal leaves the open buffer intact for retry rather than committing dangling pointers.
Consistency Verification
After each simulated crash, a consistency checker validates the filesystem state:
Consistency Checks
verify_all()
enumerate_inodes()
enumerate_tombstones()
enumerate_orphans()
walk_directory_tree()
verify_directory_counts()
verify_nlink_counts()
verify_directory_nlinks()
find_orphaned_inodes()
verify_orphan_set_drained()
verify_stats_counters()
verify_tombstones()
verify_file_extents()
verify_inode_counter()
verify_orphaned_extents()
verify_dir_entry_scan_consistency()
verify_orphaned_directory_metadata()
verify_dir_cookie_counters()
The checker detects: dangling references, orphaned inodes, nlink mismatches, missing extents, stale tombstones, counter inconsistencies, and directory entry corruption.
Test Methodology
For each failpoint, the test suite:
- Performs a filesystem operation with the failpoint enabled
- Aborts the operation at the injection point and tears down the filesystem instance, discarding all in-memory state
- Restarts the filesystem from object storage
- Runs the full consistency checker
- Verifies either complete rollback or complete commit, never partial state
This verifies that the atomicity and ordering guarantees hold at each injected crash point.
Deterministic Simulation Testing
The data path, namespace mutations, segment garbage collection, compaction, and crash recovery also run under deterministic simulation. A simulated world executes on a single-threaded runtime with virtual time: every object-store operation is assigned a pseudo-random latency from a seeded generator, and those latencies decide the order in which concurrent tasks wake. One seed defines one exact schedule, hours of simulated activity execute in seconds, and the same seed replays the same run. Seeds are drawn fresh from OS entropy on every run and printed, so repeated runs accumulate coverage and any failure is reproducible from its printed seed. A companion test replays one seed and compares the two runs' complete execution traces, so replay determinism is itself under test.
Each world races ordinary file writers (writes, truncates, hole punches, verified reads, whole-file scans, and fsyncs), two disjoint-region writers on one shared inode, a namespace actor, and live garbage-collection passes. The namespace actor creates and removes files and directories, mutates file content, renames and overwrites entries, creates hardlinks, symlinks, and special nodes, retries idempotent creates, and exercises open-unlink reclamation. The world runs over an in-memory object store in the production durability configuration and behind the production retry layer. Some worlds also inject seeded transient store faults, including puts and deletes whose effect lands but whose response is lost, so retries collide with their own already-applied writes. At a seeded instant the world crashes mid-flight. Every task is cancelled at whatever await point it happens to occupy, the store rejects all in-flight I/O (a dead process completes no writes), and a fresh instance recovers from the surviving objects.
At every quiesced point and after every crash, the state must satisfy a model-based oracle:
- Each ordinary file and shared-inode region equals the replay of an operation prefix no older than the last acknowledged fsync. One in-flight operation of unknown fate is permitted per actor at the cut, and the shared file's durable size is fixed.
- The namespace tree is structurally isomorphic to a prefix of its operation log: paths, node kinds and values, file contents, and hardlink identity must all match.
- Every referenced extent still resolves: the filesystem is re-read in full, so a dangling frame pointer or a wrongly deleted segment surfaces immediately. Modeled data files also require a key for every nonzero extent and reject keys beyond EOF.
- Segment accounting reconciles through typed snapshots: each counter's live bytes equal the byte sum of the extent pointers referencing it, live bytes never exceed appended bytes, and every referenced segment has a counter.
- All incremental footprint gauges (segment count, appended bytes, live bytes, and reclaimable bytes) equal an authoritative scan.
- The full consistency checker passes, including directory and link invariants, extent readability and EOF bounds, orphan metadata, inode and directory-cookie counters, and the global stats behind statfs.
Garbage-collection passes run with seeded per-pass tuning so the rarely-taken paths execute too: checkpoint-pinned passes, the write-cold gates (tail scrub, chain compaction), small round budgets that exercise the gather caps, and multi-batch drains.
A companion mode composes the simulation with failpoints. Some windows are too narrow for a timer to land in (a read-decide-act gap with no await inside it), so each round can arm a failpoint that crashes the world at that exact code point, and can widen such a gap by a few virtual milliseconds so concurrent commits land inside it before the code acts. This combination reproduces historical bugs whose window is the space between a garbage-collection decision and the irreversible action it drives. The failpoint suite remains alongside as the deterministic pointwise guard for each window.
CI runs the simulation as a dedicated test target with the required SlateDB and Tokio configuration flags. A scheduled job additionally soaks fresh seeds for 50 minutes every hour.
Running the simulation
export DST_RUSTFLAGS="--cfg dst --cfg tokio_unstable --cfg io_uring_skip_arch_check"
RUSTFLAGS="$DST_RUSTFLAGS" cargo test --test dst
# A timed soak: fresh random seeds, fanned across all cores, until the
# budget elapses. Scale depth with DST_ROUNDS / DST_OPS / DST_CRASH_PCT.
DST_WALL_CLOCK_SECS=3600 RUSTFLAGS="$DST_RUSTFLAGS" \
cargo test --test dst -- --nocapture seeds
# Failpoint-placed crashes and widened windows
RUSTFLAGS="$DST_RUSTFLAGS" \
cargo test --features failpoints --test dst crash_points
# Reproduce a failure from its printed seed
DST_SEEDS=16039913183294875603 RUSTFLAGS="$DST_RUSTFLAGS" \
cargo test --test dst -- --nocapture
Continuous Integration Testing
In addition to crash simulation, CI runs pjdfstest, stress-ng, Linux kernel builds, Jepsen's local-fs suite, and a ZFS integration test on every commit.
POSIX Compliance
pjdfstest: A POSIX filesystem compliance test suite originally developed for FreeBSD. Tests cover file creation, permissions, hard links, symbolic links, timestamps, and other POSIX semantics. 8,662 cases run once per protocol: NFS, 9P, and FUSE. A few cases per protocol are excluded; the exclude lists are public in the repo.
Stress Testing
stress-ng: Exercises filesystem operations under concurrent load, including directory operations, file metadata, links, renames, and attribute modifications. Tests run over NFS, 9P, and FUSE.
Linux Kernel Compilation: Compiles the Linux kernel source tree on ZeroFS, exercising parallel file creation, compilation, and linking across thousands of files.
Model-Based Testing
Jepsen (local-fs): Generates random sequences of filesystem operations and checks each result against a reference model, shrinking any divergence to a minimal failing case. It runs over a 9P mount. A crash mode injects the loss of un-fsynced writes: it kills the server mid-run, dropping the in-memory open segment buffer and memtable, then recovers from the object store and verifies the surviving state is consistent with the last fsync.
Layered Filesystem Testing
The CI suite includes a test that creates a ZFS pool on a ZeroFS NBD block device:
- Create a 3GB block device file via 9P
- Connect via NBD and create a ZFS pool
- Extract the Linux kernel source (~80,000 files)
- Compute checksums of all files
- Export the ZFS pool and restart ZeroFS
- Reimport the pool and verify all checksums match
This test validates data integrity through multiple filesystem layers and across process restarts.
Test Matrix
| Test Suite | Protocol | Coverage |
|---|---|---|
| Unit tests | n/a | Core filesystem logic |
| Failpoint crash tests | n/a | Crash consistency at each operation stage |
| Deterministic simulation | n/a | Data and namespace models, GC/compaction schedules, accounting invariants, and mid-flight crash recovery with exact replay |
| Jepsen (local-fs) | 9P | Model-based correctness, crash consistency |
| pjdfstest | NFS, 9P, FUSE | POSIX compliance |
| stress-ng | NFS, 9P, FUSE | Concurrent operations under load |
| Kernel compilation | NFS, 9P, FUSE | Parallel build workload |
| ZFS integration | NBD + 9P | Block device integrity across restarts |
All tests run on every pull request and merge to the main branch.
Conditional Writes and Fencing
ZeroFS uses conditional writes (put-if-not-exists) for fencing to prevent split-brain scenarios when multiple instances access the same storage backend. This ensures that only one writer can be active at a time.
AWS S3, Azure Blob Storage, and Google Cloud Storage support conditional writes natively. For S3-compatible object stores that do not support conditional puts, ZeroFS can use Redis as a coordination backend:
[aws]
conditional_put = "redis://localhost:6379"
When configured, ZeroFS uses Redis to coordinate conditional write operations, providing the same fencing guarantees as native conditional put support. See the Configuration Guide for details.
Backend Durability
ZeroFS delegates storage durability to the object storage backend.
| Backend | Designed Durability | Replication |
|---|---|---|
| Amazon S3 | 99.999999999% | Automatic across availability zones |
| Azure Blob Storage | 99.999999999% | Configurable geo-redundancy |
| Google Cloud Storage | 99.999999999% | Configurable multi-region |
These services maintain multiple replicas across independent failure domains. Once data is persisted to object storage, it is protected against hardware failures, facility outages, and other localized events without additional configuration.
Summary
ZeroFS provides the following durability and consistency guarantees:
- Name
Write Atomicity- Description
Filesystem operations are atomic. Each operation either completes fully or has no effect. Partial or torn writes are not possible.
- Name
Total Ordering- Description
All write operations flow through a single-writer commit path that defines a total order over batches. This order is preserved across crashes. If operation B is visible after recovery, all operations that completed before B started are also visible.
- Name
Immediate Recovery- Description
No recovery procedure is required after unexpected termination. The filesystem resumes operation by reading the current manifest from object storage.
- Name
POSIX Durability- Description
Data is persisted to stable storage upon fsync() invocation, consistent with POSIX semantics and the behavior of other filesystems.
- Name
Backend Replication- Description
Persisted data inherits the durability properties of the object storage backend, typically providing automatic replication across multiple failure domains.