Skip to content

docs/vfs: the --writable design, the commit-per-write trade, and the eager/lazy choice - #12

Merged
Andrew Gazelka (andrewgazelka) merged 2 commits into
ix-patchedfrom
vfs-writable-design
Aug 1, 2026
Merged

docs/vfs: the --writable design, the commit-per-write trade, and the eager/lazy choice#12
Andrew Gazelka (andrewgazelka) merged 2 commits into
ix-patchedfrom
vfs-writable-design

Conversation

@andrewgazelka

@andrewgazelka Andrew Gazelka (andrewgazelka) commented Aug 1, 2026

Copy link
Copy Markdown
Member

Design only. No code, and nothing here is measured against a prototype.

--writable is the name docs/vfs.md already reserves for a mount backed by a real jj workspace. The obvious reading is a commit per write, and one number from the existing --scratch measurements disqualifies it: a single bun install through the mount is 290,879 writes, so a commit per write is 290,879 commits and an operation log of the same order. Batching does not help, because every batch boundary is a guess about when a tool stopped writing and a wrong guess splits a file across two revisions.

The alternative is that the mount becomes the workspace's working copy, and jj already has the seam. WorkingCopy, LockedWorkingCopy and WorkingCopyFactory in lib/src/working_copy.rs, chosen by a string in .jj/working_copy/type. Upstream anticipated this exact case: the set_sparse_patterns TODO mentions "working copies that don't support sparse checkouts (e.g. because they use a virtual file system)".

Four conclusions in the doc that were not obvious at the start:

The tracking rule is jj's, from SnapshotOptions, not a gitignored-versus-not rule of our own. The argument is agreement. Two definitions of "tracked" in one filesystem means jj status run inside the mount reports a different file set from the one the mount is serving, with no way for the user to tell which is right. Adopting jj's rule also settles a case a gitignore rule gets wrong: a new source file that is untracked and not ignored, which --scratch sends to the disposable layer and throws away.

The mount is not "never dirty". It is dirty between a write and the next snapshot, like any working copy. What is cheap is measuring it: the server handled every write, so producing the tree costs writes-since-snapshot rather than walking 38,517 entries. That is the bookkeeping, not the transport, and the doc says so.

The missing NFSv3 close shrinks rather than disappears. No commit boundary to infer, but jj commands are separate processes, so the server must publish tree state for them and that needs a flush rule. The rule and its cost are both named: a jj status landing inside the window reads a tree a few hundred milliseconds stale, corrected on the next flush. No data lost, no wrong commit.

Whiteouts do not carry over, recorded as a rejected alternative with its reasoning and with the one condition that would bring it back.

The doc also carries the risk most likely to bite (three pieces of scratch state are sound only because one process holds the flock, and the design adds a fourth that another process is meant to read), an explicit list of what was not verified, and a size of several days broken into four steps.

All nine code citations were checked against the files at this revision. mkdocs build --strict and codespell pass locally.

Note

Document the --writable VFS design, commit-per-write tradeoffs, and flush strategy

Adds a design section to docs/vfs.md covering a proposed --writable mode for a VFS backed by a real jj workspace.

  • Explains why commit-per-write is infeasible (e.g. 290,879 writes during bun install) and proposes accumulating writes then snapshotting into @ at flush-like boundaries (idle timeout, NFS COMMIT, hard cap).
  • Outlines integration with jj's WorkingCopy/LockedWorkingCopy/WorkingCopyFactory traits and workspace loader, using SnapshotOptions for tracking rules.
  • Covers dirtiness tracking, eager vs. lazy content comparison, and why whiteouts are unnecessary for --writable.
  • Notes risks around cross-process state, locking, NFSv3's lack of a close operation, and unverified assumptions, plus a rough implementation size estimate.

Macroscope summarized 5b9722d.

`--writable` is the reserved name for a mount backed by a real jj workspace.
The obvious reading is a commit per write, and one number from the `--scratch`
measurements rules it out: a single `bun install` through the mount is 290,879
writes, so a commit per write is 290,879 commits and an operation log of the
same order, with `jj undo` stepping back one `write(2)` at a time. Batching
does not rescue it, because any batch boundary is a guess about when a tool
finished writing and a wrong guess splits a file across two revisions.

The alternative is that the mount becomes the workspace's working copy. jj
already has the seam: `WorkingCopy`, `LockedWorkingCopy` and
`WorkingCopyFactory` in `lib/src/working_copy.rs`, selected by a string in
`.jj/working_copy/type`. Upstream anticipated this case in as many words, in
the `set_sparse_patterns` TODO about working copies that use a virtual file
system.

Records four conclusions that were not obvious going in.

The tracking rule is jj's, from `SnapshotOptions`, not a gitignored-versus-not
rule of our own. The argument is agreement rather than taste: two definitions
of "tracked" in one filesystem means `jj status` run inside the mount reports a
different set of files from the one the mount serves. It also settles three
cases a gitignore rule leaves open, including a new source file that is
untracked and not ignored, which `--scratch` currently throws away.

The mount is not "never dirty". It is dirty between a write and the next
snapshot, exactly as a local working copy is. What is cheap is measuring the
dirt, because the server handled every write and knows the changed set, so
producing the tree costs writes-since-snapshot rather than the 38,517 entries
in the tree. That comes from the bookkeeping and not from the transport, and
the doc says so, so nobody later credits NFS or FSKit for it.

The missing NFSv3 `close` does not stop being a problem, it becomes a smaller
one. There is no commit boundary to infer, but jj commands are separate
processes, so the server has to publish tree state for them to read, and that
needs a flush rule. Named it and named its cost: a `jj status` inside the
window reads a tree a few hundred milliseconds stale and the next flush
corrects it. No data lost and no wrong commit.

Whiteouts do not carry over. They exist because `--scratch` sits on a revision
that cannot be edited; under `@` a deleted path is a path absent from the next
tree. Recorded as a rejected alternative with the reason, and with the one
condition that brings it back, because an alternative rejected silently gets
re-proposed.

Also records the risk worth watching, that three pieces of scratch-layer state
are sound only because one process holds the `flock` and step 3 adds a fourth
that another process is meant to read; what was not verified; and a size, which
is several days.
… writing

Two decisions were being made in conversation rather than in the repo, and both
have a cost that only reads as a cost with a number beside it.

The first is whether a commit object is written per write. The previous text
argued against it from volume alone, which is arguable. Adds the argument that
is not: a writer emits a file in chunks, so a commit taken after each
`write(2)` holds a half-written file, and almost none of the 290,879 commits
would be a state anyone could return to. Also states what the alternative gives
up, which the previous text did not: between a write and the next fold into
`@`, bytes live in the server's storage rather than the object store, so a
crash in that window depends on the server rather than on jj.

Proposes the middle. Fold writes into `@` on the same flush rule the state file
already needs, so jj amends the working-copy commit instead of adding to it.
`jj log` still shows one commit, durability lands within about a second and in
the object store, and the count is one amend per flush rather than one per
write. Ends with the question that has to be answered before the write path is
built: is durability within about a second enough, or does something need each
individual write recoverable.

The second is when the changed set is computed. Records that both readings
maintain the changed path set eagerly, because the server learns the path on
every write for free and any design that skipped it would have to walk the
tree, which is the cost the approach exists to avoid. The real choice is
whether content is compared eagerly too, and it is written out as three options
with their costs: eager paths with content compared at snapshot, which reports
a file written back to its original bytes as modified until the next snapshot;
eager paths with content hashed per write, which re-hashes a whole file once
per append and is quadratic in file size against a 290,879-write workload; and
lazy, which is exact and stale.

Recommends the first, on the grounds that the false positive is narrow, visible
only between a write and the next snapshot, and clears itself.
@andrewgazelka Andrew Gazelka (andrewgazelka) changed the title docs/vfs: the --writable design, and why it is not a commit per write docs/vfs: the --writable design, the commit-per-write trade, and the eager/lazy choice Aug 1, 2026
@andrewgazelka
Andrew Gazelka (andrewgazelka) merged commit 9846f37 into ix-patched Aug 1, 2026
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant