feat(scratch): persistent cache directories a target can declare and share - #403
Open
raphaelvigee wants to merge 1 commit into
Open
feat(scratch): persistent cache directories a target can declare and share#403raphaelvigee wants to merge 1 commit into
raphaelvigee wants to merge 1 commit into
Conversation
scratch driver — declare a cache directory many targets can share
raphaelvigee
force-pushed
the
raphaelvigee/scratch-driver
branch
from
August 29, 2026 15:34
0ba1157 to
16a872f
Compare
raphaelvigee
marked this pull request as ready for review
August 29, 2026 15:34
…share
Every sandbox starts empty. That is the hermetic default and it is correct, but
it means heph re-pays, on every miss, for work that is byte-identical across
targets and across runs — compiler caches, dependency caches, tool indexes.
# //build/BUILD — declared once
target(name = "gocache", driver = "scratch",
path = ".cache/go-build", env = "GOCACHE", access = "shared")
# any number of targets, wiring nothing
target(name = "server", driver = "bash", scratch = ["//build:gocache"],
run = ["go build ./..."]) # GOCACHE is set for the run
heph already hit this once and solved it by hand inside one driver:
`plugin-go`'s shared golist GOCACHE. Measured on a 500-package corpus, `go list`
was 778s of CPU across 1945 invocations (687s of it *system* time) against 15s
for every `go tool compile` combined; sharing the cache took the run from 205s to
84s wall. That module has no lock, no eviction, no remote and no visibility, and
is available to exactly one driver.
## The contract
> A target must produce identical outputs whether its scratch directories are
> warm, cold, or absent. **Losing one is always a slowdown and never a wrong
> answer.**
Everything below follows from it. This is the same promise Go's build cache,
ccache and sccache already make; it does *not* hold for a directory used as
durable state, and the docs say so in those words.
## A target, not an attribute
Declaring the settings inline at each use site would make every consumer restate
`access`, `version` and `remote`, then need validation that they all agree — a
*discovered* error where a declaration makes it inexpressible. There is now
exactly one copy of each. Cache identity is the addr, so packages namespace it
for free: `//go:cache` and `//rust:cache` are different caches with nobody
agreeing on a prefix convention.
No new Starlark global: heph's rule surface is `target(driver = "…")`, so this is
a builtin driver shaped like `plugingroup`, and `#[derive(Spec)]` supplies both
the parser and the LSP schema.
## Nothing about a scratch reaches `hashin`
A reference is an `Input` with `hashed: false, runtime: false` — the one
combination nothing else uses. It materializes no artifacts, and it must not
touch the consumer's cache key.
The tempting alternative is to fold the declaration in, so bumping `version`
rebuilds users of the cache. That is an over-hash: if outputs really are
identical warm-or-cold, a fresh slot changes nothing and the rebuild is pure
waste; if they are not, the target is already broken and rebuilding is not the
fix. So a `version` bump gives every consumer a fresh empty slot and invalidates
nothing — exactly what you want when the reason for bumping is "the old cache
went bad". Asserted against the **def hash**, not `hashout`: a target whose key
moved still produces identical bytes, so a hashout comparison would pass while
the cache missed on every run.
## Mounting is one symlink
Per target, pointing out of the sandbox at the canonical slot directory. Teardown
removes the link, not the tree (`remove_dir_all` does not follow symlinks) — the
same property read-only input staging already relies on for the 11k-file Go SDK.
The measurements rule out the alternative: seeding a warm cache *into each
sandbox* cut `go list` CPU 778s -> 309s and moved wall time by **exactly zero**,
because the cost was never CPU but the ~500 filesystem entries created and
destroyed per sandbox.
The link target is the *canonical* path, not something sandbox-local. Tools bake
absolute paths into their cache entries, so if every consumer saw its own path
the cache would restore and be inert — present, and useless.
The bridge creates it rather than the engine, because the bridge owns sandbox
creation: the FUSE path may redirect the package dir into a mount, so there is no
earlier moment at which the directory reliably exists.
## The one wrong-build guard
A scratch that would land where an input already did is a hard error. It is the
only way a scratch can cause a wrong build rather than a slow one — the target
would read cache contents where it believes it reads a declared dependency,
bytes no `hashin` describes.
## Two author assertions, both defaulting conservatively
- `access = "shared"` says the tool is safe under concurrent access. Go's build
cache is the motivating case — it is what `go build -p N` does — so forcing it
exclusive would serialize a whole Go build. Defaults to `exclusive`, and the
keyed cross-process lock ships in this change, because an `exclusive` that does
not serialize is a silent lie. Guards are taken in sorted slot order (so two
targets naming the same pair in opposite orders cannot deadlock) and acquired
after dep resolution but before the worker permit (after deps, or a dep needing
the same slot could never get it; before the permit, so a queued target holds
no worker and the wait is provably bounded).
- `platform = "any"` says the contents are portable, which lets one slot serve
every machine. It asserts two things: no host dependence *and* no embedded
absolute paths. Defaults to `os_arch`, because restoring a host-specific cache
onto the wrong host is the one mistake here that is not merely slow.
## Compatibility
`ABI_SEMVER` 0.7.0 -> 0.8.0. `RunRequest`/`ManagedRunRequest` gain
`repeated ScratchMount scratch`: additive and cold-path — a prost wire field, not
a vtable change — so an old plugin decodes a new host's request and ignores the
mounts. Its targets then run without a scratch, which costs a cold cache and
never a wrong build: the lock is keyed on a declaration an old plugin cannot see,
so there is no shared directory for it to race either.
Declaration and reference need no ABI surface at all; only mounting does. The
reference rides on `Input.annotations`, already the producer->host channel
(`read_only`/`stage_per_file` are the precedent).
Design doc: docs/SCRATCH.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FArWjycMDyWeSfHHtpgtoU
raphaelvigee
force-pushed
the
raphaelvigee/scratch-driver
branch
from
August 29, 2026 15:51
16a872f to
b174355
Compare
This was referenced Aug 29, 2026
scratch driver — declare a cache directory many targets can share
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First of three. Adds persistent cache directories that targets declare and share:
the feature working locally. #434 adds branch lineages, store management and the
remote; #435 retrofits
plugin-goonto it.Why
Every sandbox starts empty. That is the hermetic default and it is correct, but it
means heph re-pays, on every miss, for work that is byte-identical across targets
and across runs — compiler caches, dependency caches, tool indexes.
heph already hit this once and solved it by hand inside one driver:
plugin-go's shared golist GOCACHE. Measured on a 500-package corpus,go listwas 778s of CPU across 1945 invocations (687s of it system time) against
15s for every
go tool compilecombined; sharing the cache took the run from205s to 84s wall. That module has no lock, no eviction, no remote and no
visibility, and is available to exactly one driver.
Design doc:
docs/SCRATCH.md.The contract
Everything below follows from it. Same promise Go's build cache, ccache and
sccache already make; it does not hold for a directory used as durable state,
and the docs say so in those words.
A target, not an attribute
Declaring settings inline at each use site would make every consumer restate
access,versionandremote, then need validation that they all agree — adiscovered error where a declaration makes it inexpressible. Cache identity
is the addr, so packages namespace it for free:
//go:cacheand//rust:cacheare different caches with nobody agreeing on a prefix convention.
No new Starlark global — heph's rule surface is
target(driver = "…"), sothis is a builtin driver shaped like
plugingroup, and#[derive(Spec)]supplies both the parser and the LSP schema.
Nothing about a scratch reaches
hashinA reference is an
Inputwithhashed: false, runtime: false— the onecombination nothing else uses. It materializes no artifacts, and it must not
touch the consumer's cache key.
The tempting alternative is to fold the declaration in, so bumping
versionrebuilds users of the cache. That is an over-hash: if outputs really are
identical warm-or-cold, a fresh slot changes nothing and the rebuild is pure
waste; if they are not, the target is already broken and rebuilding is not the
fix. So a
versionbump gives every consumer a fresh empty slot and invalidatesnothing.
Asserted against the def hash, not
hashout— a target whose key moved stillproduces identical bytes, so a hashout comparison would pass while the cache
missed on every run. (My first version of that test made exactly this mistake.)
Mounting is one symlink
Per target, pointing out of the sandbox at the canonical slot. Teardown removes
the link, not the tree (
remove_dir_alldoes not follow symlinks) — the propertyread-only input staging already relies on for the 11k-file Go SDK.
The measurements rule out the alternative: seeding a warm cache into each
sandbox cut
go listCPU 778s → 309s and moved wall time by exactly zero,because the cost was never CPU but the ~500 filesystem entries created and
destroyed per sandbox.
The link target is the canonical path, not something sandbox-local. Tools
bake absolute paths into their cache entries, so if every consumer saw its own
path the cache would restore and be inert — present, and useless.
The bridge creates it, not the engine: the bridge owns sandbox creation and
the FUSE path may redirect the package dir into a mount, so there is no earlier
moment at which the directory reliably exists.
The one wrong-build guard
A scratch that would land where an input already did is a hard error. It is
the only way a scratch can cause a wrong build rather than a slow one — the
target would read cache contents where it believes it reads a declared
dependency, bytes no
hashindescribes.Two author assertions heph cannot check
Both default conservatively:
access = "shared"— the tool is safe under concurrent access. Go's buildcache is the motivating case (it is what
go build -p Ndoes), so forcing itexclusive would serialize a whole Go build. Defaults to
exclusive, and thekeyed cross-process lock ships in this PR, because an
exclusivethat doesnot serialize is a silent lie. Guards are taken in sorted slot order, and
acquired after dep resolution but before the worker permit — after deps, or a
dep needing the same slot could never get it; before the permit, so a queued
target holds no worker and the wait is provably bounded rather than circular.
platform = "any"— the contents are portable, so one slot serves everymachine. It asserts two things: no host dependence and no embedded
absolute paths. Defaults to
os_arch, because restoring a host-specific cacheonto the wrong host is the one mistake here that is not merely slow.
Compatibility
ABI_SEMVER0.7.0 → 0.8.0.RunRequest/ManagedRunRequestgainrepeated ScratchMount scratch: additive and cold-path — a prost wire field, nota vtable change — so an old plugin decodes a new host's request and ignores the
mounts. Its targets then run without a scratch, which costs a cold cache and
never a wrong build: the lock is keyed on a declaration an old plugin cannot
see, so there is no shared directory for it to race either.
Declaration and reference need no ABI surface at all; only mounting does. The
reference rides on
Input.annotations, already the producer→host channel(
read_only/stage_per_fileare the precedent).Tests
17 driver unit + 3 pluginexec parse + 6 engine resolver + 14 e2e. The
load-bearing one is
a_scratch_carries_state_between_runs: run 1 sees an emptycache and writes a marker, run 2 (a genuine re-execute) reads it back through the
real sandbox, symlink and env var.
Worth knowing if you write another: an
EResultholds a riding read lock onits addr, so keeping one alive across
reopen()deadlocks the second engine'swrite lock. It only bites when the second run re-executes; a cache hit coexists.
Cost ~30 minutes of hang and surfaced as an unrelated
ENOENTon a lock file.