feat(scratch): mount the cache, lock the slot, set the env var - #412
Draft
raphaelvigee wants to merge 2 commits into
Draft
feat(scratch): mount the cache, lock the slot, set the env var#412raphaelvigee wants to merge 2 commits into
raphaelvigee wants to merge 2 commits into
Conversation
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
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.
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 thewhole of what a consumer writes.
Mounting is one
symlink(2)per target pointing out of the sandbox at thecanonical 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_alldoes not follow symlinks — the same propertyread-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 listCPU by 60% and moved wall time by exactly zero, because the cost wasnever 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
hashindescribes — so it must fail rather than silently win.Locking is a keyed cross-process reader/writer lock per slot,
accessdecidingwhich guard. It ships in the same change as
accessdeliberately: anexclusivethat does not serialize is a silent lie. Guards are taken in sortedslot 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/ManagedRunRequestgainrepeated ScratchMount scratch, andABI_SEMVERgoes 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 CLI • Give Feedback 💬