Ask a monorepo tool what dist/ contains after a cache hit and the
honest answer from most of them is "the cached files, plus whatever was
already there." That "plus" is the source of an entire genre of bugs:
the deleted module that keeps being served, the renamed asset with two
copies, the test fixture from a branch you checked out last week.
vx's answer is shorter. The declared outputs are exactly the cached
snapshot.
The rule
Every task with a cache block declares outputs.files. vx treats
those globs as territory the task owns:
-
Before a miss executes, the current matches are removed. A
leftover
dist/old.jsfrom a previous build cannot be picked up by the new build's globs and cached as if it were produced now. - Before a hit restores, the current matches are removed. The post-restore tree is the snapshot, not the snapshot merged with the present.
Outputs are a task's territory in the other direction too: two tasks
whose output declarations provably overlap (equal literals, or a
literal that another task's glob matches) are refused when the graph
is built, because a restore of one would delete the other's work. Two
globs that only might overlap are let through, and there the last
restore wins. By default the refusal holds even when one task depends
on the other (rules.exclusiveOutputs in vx.workspace.ts). Set it to
false and vx accepts the ordered overlap: the second task runs after
the first and adds to the tree (twenty's build:individual writing
dist/individual into build's dist), and vx caches exactly what it
added, never the files it found there. It is correct, only slower, so
the better fix is giving each task its own output path.
Why it is also the fast path
Owning the outputs is what makes the warm-on-warm case cheap. Because
vx knows the tree after any hit is the snapshot, it records a
fingerprint per output file (size, mode, mtime-ms, inode, ctime) alongside the
entry. On the next hit it checks two things:
- The set. The files under the output globs must be exactly the recorded set, no more, no fewer.
- The files. Every recorded fingerprint must match the file on disk.
If both hold, the restore is N stats and zero writes, zero
decompression. That is the "current tree" short-circuit, and it is the
no-op row in the benchmarks: every task a hit over
an intact tree. Their restore row deletes the outputs first, so every
artifact is extracted, and it costs more. A tool that merges cannot
skip that work when nothing is gone; it does not know what "current"
means for a directory it only ever adds to.
What the wipe never touches
- Files outside the declared globs. A task that writes somewhere it did not declare is a bug the sandbox can catch, but the wipe itself is bounded by the declaration.
- Another project's directory. Boundaries are hard.
-
.gitand the.vxcache directory, whatever the glob says. -
node_modules, unless a glob names it.**/*.jsleaves installed files alone;node_modules/**is a legitimate output of an install task.
A task with no cache block declares no outputs and owns nothing. It
runs every time and vx does not touch its tree.
The consequences you feel
The one you notice first: git status after a hit is clean in the way
you expect, because the restore did not leave a merged pile behind. The
one you notice never: the build that would have shipped a deleted file.
Cache correctness is the worst failure class this tool can have, a
stale hit replays wrong bytes under a green check, and strict ownership
is the cheapest rule that removes one whole species of it.
Originally published on the vx blog. vx is an MIT task runner and build cache for JS monorepos: github.com/vznjs/vx.
Written with AI assistance.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more