SFEP-0040
Global Artifact Cache Store and Garbage Collection
- Status
- Accepted
- Type
- tooling
- Created
- Updated
- Author
- agent:compiler-architect; human review
- Tracking
- #1892, #1893
SFEP-0040 — Global Artifact Cache Store and Garbage Collection
1. Summary
Sailfin already has the hard part of a good build-artifact story — a real
content-addressed incremental cache (build/cache/v2, keyed on a hash of
source, dependency hashes, compiler version, flags, and slug, published by
atomic rename). But it ships that cache with two foot-gun defaults: it lives at
a per-project, CWD-relative path, and it grows without bound (no
eviction, no GC — only --clean/make clean-build reclaims it). The result is
the exact “Rust target/ is heavy” complaint: every project duplicates its own
artifact cache, and each one grows forever. This SFEP changes two defaults and
adds one command: (1) default the content-addressed cache to a shared per-user
directory (XDG ~/.cache/sailfin), keeping the existing overrides; (2) add
sfn cache (prune / clean / info) with a size/age GC; (3) make a cache
schema-version bump sweep stale v<N>/ trees instead of leaving them to rot. No
language, effect, or self-hosting semantics change — this is toolchain hygiene.
2. Motivation
A social-media framing that keeps recurring: “node_modules is heavy — wait till
you see Rust’s target/.” It conflates two problems. Axis 1 is build
artifact bloat (.o/IR/binaries duplicated per project, never GC’d — Rust’s
target/). Axis 2 is dependency dedup (one physical copy of a package
version shared across projects — pnpm’s symlinked central store). Sailfin is
already sound on axis 2: external capsules resolve from a single global
version-keyed cache ~/.sfn/cache/capsules/<scope>/<name>/<version>/src/
(compiler/src/capsule_resolver.sfn:619-648), the Cargo model, no
node_modules. This SFEP is entirely about axis 1, where we have the
mechanism but the wrong defaults.
Concretely, today (compiler/src/build_cache.sfn):
- The content-addressed cache root is
cache_root()→build/cache/v2, CWD-relative, overridable only via$SAILFIN_BUILD_CACHE_DIR(build_cache.sfn:238-250). There is no global user-level default — every project checkout gets its own privatebuild/cache, so a module compiled identically in ten projects is cached ten times. - There is no eviction of any kind. A grep for
evict|prune|gc|LRU|age.?outacrosscompiler/srcfinds only a comment noting artifacts “age out via the user’s normal cache-cleanup or--cleanflow” (build_cache.sfn:84-86) — i.e. never, absent human action. The only reclaim paths are the explicit--cleanflag (cache_clean,build_cache.sfn:224-230, anrm -rfof the schema root) andmake clean-build. - A cache schema-version bump (the
v2in the path) simply starts writing to a newv<N>/and leaves the old tree in place (build_cache.sfn:47-52), so every historical schema version accretes on disk forever.
The sharing hook already exists and is safe: the cache is content-keyed and
published by atomic rename (build_cache.sfn:352-400), which is exactly why
concurrent test workers already share build/cache/v2 without corruption. We
are not defaulting to that safe-by-construction sharing across projects, and we
give the user no bounded-size story. Both are cheap to fix.
3. Design
Three independent, composable changes to compiler/src/build_cache.sfn and one
new CLI command. None touch the cache key, the entry layout
(<key[0..2]>/<key>/{ir.sfn-asm,layout.manifest,mod.ll,mod.o}), or the
atomic-publish protocol — only where the root is and when entries are
removed.
3.1 Global default cache root
cache_root() gains a resolution ladder (highest precedence first):
$SAILFIN_BUILD_CACHE_DIR/v<N>— unchanged, explicit override (CI, sandboxes).$XDG_CACHE_HOME/sailfin/v<N>if$XDG_CACHE_HOMEis set.$HOME/.cache/sailfin/v<N>— the new default on Linux/macOS.build/cache/v<N>— retained fallback when$HOMEis unresolvable (containers, minimal CI) and as the in-tree cache for the compiler’s own self-host build, which must stay hermetic underbuild/.
The compiler self-host build ([capsule].name == "sailfin", the same predicate
build_stamp.sfn:75 already keys on) pins the root to in-tree build/cache so
make compile / make check remain hermetic and reproducible — the global
store is a user-project convenience, never a self-host input.
// build_cache.sfn — resolution sketch (illustrative)fn cache_root() -> string ![io] { let explicit = env.get("SAILFIN_BUILD_CACHE_DIR"); if explicit.length > 0 { return explicit + "/" + _schema_dir(); } if _is_compiler_selfhost() { return "build/cache/" + _schema_dir(); } let xdg = env.get("XDG_CACHE_HOME"); if xdg.length > 0 { return xdg + "/sailfin/" + _schema_dir(); } let home = env.get("HOME"); if home.length > 0 { return home + "/.cache/sailfin/" + _schema_dir(); } return "build/cache/" + _schema_dir(); // hermetic fallback}Because entries are content-addressed, cross-project sharing at the global root
is sound with no new locking: two projects that compile the same
(source, deps, compiler-version, flags, slug) tuple hit the same key and the
same atomically-published entry. This is the pnpm-central-store win, applied to
build artifacts — the thing Rust’s target/ never did.
Unchanged and still local: the non-content-addressed driver scratch
(build/sailfin/program.ll, program.o, import-context, build-stamp). These
are clobber-unsafe by design and must stay per-project / per-invocation
(redirected by --work-dir / SAILFIN_TEST_SCRATCH as today). Only the
content-addressed cache moves.
3.2 sfn cache command
A new top-level command (compiler/src/cli/commands/cache.sfn, registered in
the dispatch table alongside lock, add, package):
sfn cache info— print the resolved cache root, entry count, and total size on disk. Zero effects beyond![io].sfn cache prune [--max-size <bytes>] [--max-age <days>]— LRU eviction. Walkv<N>/entries, stat each entry directory’s mtime (touched on cache hit so mtime is a true recency signal — see 3.4), delete oldest-first until under--max-size, and delete anything older than--max-age. Defaults chosen conservatively (e.g.--max-size~5 GiB,--max-age30 days) and documented; no implicit prune on normal builds (opt-in, likecargo’s ecosystem tools).sfn cache clean— the existing--cleanreclaim, surfaced as a subcommand (the--cleanflag stays for back-compat).
sfn cache clean --all-schemas deletes sibling v<M>/ trees for M < N (the
stale-schema sweep, see 3.3) in addition to the current schema.
3.3 Schema-bump sweep
When cache_root() is first materialized for schema v<N>, opportunistically
remove sibling v<M>/ (M < N) trees under the same base — a bounded,
idempotent cleanup that prevents dead schema versions accreting. Guarded so it
runs at most once per process and never touches $SAILFIN_BUILD_CACHE_DIR
roots the user explicitly pointed at (they may be sharing a base with other
tooling). Alternative if the eager sweep is deemed too aggressive: fold it into
sfn cache prune/clean --all-schemas only. (See §6.)
3.4 Mtime-on-hit for honest LRU
For prune to evict by true recency rather than creation time, a cache hit
must touch the entry’s mtime. Add a single fs touch of the entry directory in
the hit path (build_cache.sfn lookup, near the existing read at ~:352-400).
This is the only change to the hot path and is a metadata-only write.
4. Effect & capability impact
None to the language or effect system. All new surface is ![io] (filesystem
reads, stats, unlinks, and an env lookup for the cache root) — the same effect
class the cache already carries. sfn cache is an ![io] command like lock.
No new capability is introduced and no effect taxonomy change is required.
5. Self-hosting impact
No compiler pass changes — this touches the build driver / cache layer
(build_cache.sfn, a new cli/commands/cache.sfn), not lexer → parser → AST →
typecheck → effects → emitter → LLVM lowering. The self-hosting invariant is
preserved by construction: the compiler’s own build pins the cache root to
in-tree build/cache (§3.1), so make compile / make check stay hermetic and
never read a developer’s global ~/.cache/sailfin. The change is validated the
usual way — make compile must self-host after the edit, make check before
declaring it shipped. Risk is low because the cache key and entry format are
untouched; only root resolution and removal are new.
6. Alternatives considered
- Do nothing (status quo). The mechanism exists; users can already set
$SAILFIN_BUILD_CACHE_DIRand run--cleanby hand. Rejected: defaults are the product. “Set an env var and remember torm -rf” is exactly the ergonomic gap that makes Rust’starget/a meme. - pnpm-style per-file symlink store. Symlink individual artifacts from a
content-addressed store into a per-project tree. Rejected: our unit of caching
is a whole compiled module (
.ll+.o+manifest), not a file tree we assemble; the resolver already reads straight from the keyed store, so there is nothing to symlink into. Symlinks would add per-build fan-out and Windows-portability cost (SFEP-0021) for no dedup we don’t already get. - Automatic prune on every build. Rejected as a default: surprising latency
and delete-under-concurrency hazards. Prune stays explicit (
sfn cache prune), optionally wired into CI. A size watermark warning on build (non-deleting) is a possible future addition. - Eager schema-sweep vs. sweep-only-on-prune (§3.3). The eager sweep keeps
disk tidy with zero user action but deletes on first build after an upgrade;
the conservative option confines all deletion to explicit
cachesubcommands. Open question for the design gate — default proposed is the guarded eager sweep, with a flag to opt out.
7. Stage1 readiness mapping
This is a toolchain/driver change, not a language feature, so the parse/typecheck /emit/lower rows are N/A (no new syntax). The applicable bar:
- Parses — N/A (no grammar change)
- Type-checks / effect-checks — new
![io]command type-checks clean - Emits valid
.sfn-asm— N/A - Lowers to LLVM IR — N/A
- Regression coverage — see §8
- Self-hosts —
make compilegreen; compiler build pins in-tree cache root -
sfn fmt --checkclean on every touched.sfn - Documented in
docs/status.md+docs/proposals/0006-build-architecture.md(build/cache section) and the CLI reference
8. Test plan
compiler/tests/ coverage, all Sailfin-native (.sfn, per no-bash-e2e.md):
- Unit (
build_cacheroot resolution):$SAILFIN_BUILD_CACHE_DIRwins;$XDG_CACHE_HOMEnext;$HOME/.cache/sailfindefault; hermeticbuild/cachefallback when$HOMEis empty; self-host predicate pins in-tree. Drive via a helper that reads the resolved root under a scoped child env. - e2e (
compiler/tests/e2e/cache_command_test.sfn): with a scratch cache root threaded via$SAILFIN_BUILD_CACHE_DIR, build a fixture twice → second build is a cache hit;sfn cache inforeports ≥1 entry and a plausible size;sfn cache prune --max-size 0empties it;sfn cache cleanremoves the tree. Usewith_env/ a per-child env array (never process-global mutation). - e2e (schema sweep): pre-create a sibling
v<N-1>/dir under the scratch root, run a build, assert the stale tree is gone (eager sweep) or survives untilsfn cache clean --all-schemas(whichever §6 resolution is chosen). - Hermeticity guard: assert a compiler self-host build does not read/write
$HOME/.cache/sailfin(root pinned in-tree) — protectsmake check.
9. References
- Ground truth:
compiler/src/build_cache.sfn(cache key:772-799, root:238-250, atomic publish:352-400, no-GC comment:84-86, schema-rot comment:47-52,--clean:224-230). - Axis-2 dependency cache (out of scope here, already sound):
compiler/src/capsule_resolver.sfn:619-648. - SFEP-0006 (
0006-build-architecture.md, Implemented) — build architecture the cache sits under; its build/cache section is the graduation target. - SFEP-0002 (
0002-package-management.md, Draft) — imagines acachecommand surface; stale, superseded on this axis by this SFEP. - SFEP-0021 (
0021-windows-native-selfhost.md) — Windows path portability constraint informing the no-symlink decision (§6). - Prior art: Cargo
~/.cargo, pnpm content-addressed store,ccache/sccache(content-addressed compile caches with size-bounded LRU).