fs-transaction 0.1.0

Multi-file filesystem transactions that survive a crash: staged change sets, all-or-nothing apply, write-ahead recovery
Documentation
  • Coverage
  • 100%
    125 out of 125 items documented2 out of 79 items with examples
  • Size
  • Source code size: 188.39 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 1.94 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 2s Average build duration of successful builds.
  • all releases: 2s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • Homepage
  • diaryx-org/fs-transaction
    0 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • adammharris

fs-transaction

Multi-file filesystem transactions that survive a crash. Stage a set of writes, renames, removals and copies; apply them all-or-nothing; and if the power goes out halfway through, finish the job on the next run.

The files stay ordinary files. This is not a virtual filesystem and not a database — nothing here changes how your tree is read, only how it is written.

[dependencies]
fs-transaction = "0.1"
use fs_transaction::{ChangeSet, Result, StdFs, exec::block_on, recover};
use std::path::Path;

fn rearrange(root: &Path) -> Result<()> {
    // Finish anything a previous crash left journaled, before reading the tree.
    block_on(recover(&StdFs, root))?;

    let mut change = ChangeSet::new();
    change.write("a.md", "hello");
    change.rename("old.md", "b.md");
    change.remove("stale.md");

    // All four land, or none of them do.
    block_on(change.apply(&StdFs, root))
}

Why

Single-file atomic writes are a solved problem — write a temporary sibling, fsync it, rename it over the target. What is not solved is a change that touches several files at once. Rename a note in a linked set and every file pointing at it has to be rewritten too; issue those one at a time and an I/O error partway through leaves the tree torn, updated in the files already written and stale in the ones never reached.

A ChangeSet closes that window from both sides:

  • On an error — a full disk, a permission fault — every op already applied is unwound in reverse, and the tree ends up exactly as it was.
  • On a crashkill -9, a power cut — there is no error to catch and no chance to unwind, so a write-ahead journal written before the first file is touched survives instead. The next recover replays it forward to the fully-applied state.

Both endpoints are consistent. They are simply different consistent states, and the crate does not pretend a lost-power change never happened when its intent was already durably on disk.

What it does not do

Stated plainly, because a crate in this position should be:

  • Single writer. There is no locking. Two processes applying sets against one root will race, and the stale-journal guard is a check-then-act, not a mutex. Serialize them yourself.
  • A set is bounded by memory. Staged bytes and the undo buffer are both held for the length of the apply. FileOp::CopyFrom is the escape hatch for a large payload already on disk — it journals a reference, so restoring a captured tree costs O(files) of journal rather than a second copy of every byte. The source must be immutable for that to be sound; a content-addressed blob is, by construction.
  • Futures are not required to be Send. The storage port uses native async fn, so a backend keeps its own future types — which also means an apply over a non-Send backend cannot be tokio::spawned.
  • Symlinks are not resolved. The path guard that keeps a staged op inside the root is purely lexical.

Any backend

Everything is generic over a small async port. StdFs and an InMemoryFs ship with the crate; an adapter for OPFS, IndexedDB, or a network store is a few dozen mechanical lines, since the method set mirrors std::fs exactly.

Durability is declared, never assumed. A backend says what it can keep through Capabilities, and the apply path picks the strongest protocol that backend actually supports — rather than assuming atomic rename and silently lying on the backends that do not have one. Every durability member defaults to the pessimistic answer, so an adapter that forgets to override one degrades to the defensive path instead of to a false promise.

Journal naming

The journal is one transient dotfile at the root, present only between a change set's commit point and its completion. It defaults to .fstx-journal; Journal::named takes your own.

Apply and recovery must agree about that name — if they disagree, nothing fails loudly, recovery simply looks where no journal is and leaves the change stranded. That is why both operations hang off Journal rather than taking the name separately.

Zero dependencies

A crate whose whole job is getting bytes onto disk correctly should not make you audit anyone else's code to trust it, and should not drag a runtime into a build that already has one. The checksum is hand-rolled FNV-1a; the error type is a hand-written enum.

License

MIT or Apache-2.0, at your option.