Skip to content

feat(scratch): mount the cache, lock the slot, set the env var - #412

Draft
raphaelvigee wants to merge 2 commits into
raphaelvigee/scratch-refsfrom
raphaelvigee/scratch-mount
Draft

feat(scratch): mount the cache, lock the slot, set the env var#412
raphaelvigee wants to merge 2 commits into
raphaelvigee/scratch-refsfrom
raphaelvigee/scratch-mount

Conversation

@raphaelvigee

Copy link
Copy Markdown
Member

Makes a scratch declaration do something. A referenced cache is now materialized
into the sandbox, guarded against concurrent use, and handed to the tool through
the variable its declaration names — so scratch = ["//build:gocache"] is the
whole of what a consumer writes.

Mounting is one symlink(2) per target pointing out of the sandbox at the
canonical slot directory. That is the entire mechanism, and it is why a scratch
costs an inode per target rather than a copy: teardown removes the link, not the
tree, since remove_dir_all does not follow symlinks — the same property
read-only input staging already relies on for the Go SDK. Measurements on a Go
corpus are what rule out the copy: seeding a warm cache into each sandbox cut
go list CPU by 60% and moved wall time by exactly zero, because the cost was
never CPU but the half-thousand 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 symlink is created by the bridge 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. Putting it
there also covers both sandbox modes at once.

A scratch that would land where an input already did is a hard error. This is the
one 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 — so it must fail rather than silently win.

Locking is a keyed cross-process reader/writer lock per slot, access deciding
which guard. It ships in the same change as access deliberately: 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 the acquire sits after dependency resolution but before the worker
permit — after deps, or a dep needing the same slot could never get it; before
the permit, so a target queued on a contended slot holds no worker, which also
makes the wait provably bounded rather than circular.

Slot identity is (addr, version, platform-components). platform = "any"
therefore gives one slot for every machine, which is what will let a portable
cache travel between a Linux CI runner and a macOS laptop.

ABI: RunRequest/ManagedRunRequest gain repeated ScratchMount scratch, and
ABI_SEMVER goes 0.5.0 -> 0.6.0. 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. The cdylib path is not
optional here, since plugin-go is a cdylib and is the reason this exists.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FArWjycMDyWeSfHHtpgtoU


Stack created with GitHub Stacks CLIGive Feedback 💬

raphaelvigee and others added 2 commits August 22, 2026 16:03
Makes a scratch declaration do something. A referenced cache is now materialized
into the sandbox, guarded against concurrent use, and handed to the tool through
the variable its declaration names — so `scratch = ["//build:gocache"]` is the
whole of what a consumer writes.

Mounting is one `symlink(2)` per target pointing out of the sandbox at the
canonical slot directory. That is the entire mechanism, and it is why a scratch
costs an inode per target rather than a copy: teardown removes the link, not the
tree, since `remove_dir_all` does not follow symlinks — the same property
read-only input staging already relies on for the Go SDK. Measurements on a Go
corpus are what rule out the copy: seeding a warm cache into each sandbox cut
`go list` CPU by 60% and moved wall time by exactly zero, because the cost was
never CPU but the half-thousand 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 symlink is created by the bridge 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. Putting it
there also covers both sandbox modes at once.

A scratch that would land where an input already did is a hard error. This is the
one 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 — so it must fail rather than silently win.

Locking is a keyed cross-process reader/writer lock per slot, `access` deciding
which guard. It ships in the same change as `access` deliberately: 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 the acquire sits after dependency resolution but before the worker
permit — after deps, or a dep needing the same slot could never get it; before
the permit, so a target queued on a contended slot holds no worker, which also
makes the wait provably bounded rather than circular.

Slot identity is `(addr, version, platform-components)`. `platform = "any"`
therefore gives one slot for every machine, which is what will let a portable
cache travel between a Linux CI runner and a macOS laptop.

ABI: `RunRequest`/`ManagedRunRequest` gain `repeated ScratchMount scratch`, and
`ABI_SEMVER` goes 0.5.0 -> 0.6.0. 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. The cdylib path is not
optional here, since plugin-go is a cdylib and is the reason this exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FArWjycMDyWeSfHHtpgtoU
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