Skip to content

feat(scratch): persistent cache directories a target can declare and share - #403

Open
raphaelvigee wants to merge 1 commit into
masterfrom
raphaelvigee/scratch-driver
Open

feat(scratch): persistent cache directories a target can declare and share#403
raphaelvigee wants to merge 1 commit into
masterfrom
raphaelvigee/scratch-driver

Conversation

@raphaelvigee

@raphaelvigee raphaelvigee commented Aug 22, 2026

Copy link
Copy Markdown
Member

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-go onto 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.

# //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.

Design doc: docs/SCRATCH.md.

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. 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, version and remote, then need validation that they all agree — a
discovered error where a declaration makes it inexpressible. 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.

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. (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_all does not follow symlinks) — the 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, 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 hashin describes.

Two author assertions heph cannot check

Both default conservatively:

  • access = "shared" — 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 PR, because an exclusive that does
    not 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 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).

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 empty
cache 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 EResult holds a riding read lock on
its addr, so keeping one alive across reopen() deadlocks the second engine's
write lock. It only bites when the second run re-executes; a cache hit coexists.
Cost ~30 minutes of hang and surfaced as an unrelated ENOENT on a lock file.

@raphaelvigee raphaelvigee changed the title raphaelvigee/scratch driver feat(scratch): the scratch driver — declare a cache directory many targets can share Aug 29, 2026
@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/scratch-driver branch from 0ba1157 to 16a872f Compare August 29, 2026 15:34
@raphaelvigee
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
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