← Turborepo

The Remote Cache Under Cargo, Go and uv

What a turbo cache entry is, and the rules a native build has to follow for a shared cache to stay correct.

Say a teammate edits a JSON schema that a Rust crate pulls in with include_str!. They build, the build is green, and the binary they get is the old one. Nothing is broken in the cache. It did exactly what it was told, and what it was told was incomplete.

That story is most of what you need to know about turbo's remote cache with Cargo, Go and uv. The rest is a few rules that don't depend on which version you run. Cargo, Go and uv support is still experimental, so defaults will move. These rules won't.

What an entry is

The hash comes from a fixed list: the task's input files, the hashes of the tasks it depends on, external dependencies, environment variables, arguments, the declared outputs, and a few more. For native toolchains, external dependencies include the identity of the toolchain, such as the compiler version, which turbo gets by running the tool.

key Hmisshit in either storemiss in boththenHashinputs, deps, envLocal storea directoryRemote storean HTTP serviceRestoreunpack, replay logRun the toolthe real commandSave to bothsame bytes, one passSame key, two storesA remote problem makes a run slower,never wrong. Wrong comes only fromthe key or the bytes.An entry<hash>.tar.zstthe files the outputs globs matched,plus the task logand a metadata file beside itSwitchesreadswrites--forcenoneboth--remote-onlyremoteremote--no-cachebothnone
life of a cached task · one key, two stores

Nowhere on that path does turbo check that the hash was complete. It can't, because it doesn't know what the tool read.

Remote trouble is cheap for the same reason. A 403 on read turns off remote reads and uploads for the run, and a 403 on upload turns off only uploads. After a few consecutive failures a short circuit breaker pauses remote traffic and tries again later.

What a native build has to promise

Turbo derives most of a native key itself. For Cargo that is the crate and its local dependency sources, the Cargo.lock closure and the full rustc -vV. For Go it is go.mod, go.sum, go version, and variables that change the build, like GOOS, GOFLAGS and CGO_ENABLED. For uv it is the uv.lock closure plus the identity of the uv binary and the interpreter. What turbo can't derive, you declare: env vars a build script reads go in env, files from outside the package go in inputs.

Too little in the key gives you a stale hit, a wrong result served with confidence. Too much gives you a needless miss, which is only slow. So the native defaults lean toward missing.

Outputs should be the thing you ship, not the tool's working state.

TOOLCHAINARCHIVED IN THE ENTRYSTAYS PRIVATE TO THE TOOLCargofinal bin, cdylib, staticlibtarget/, incremental stateGothe binary, if one main packageGOCACHEuvwhat you declare in outputs.venvPrivate state is full of absolute paths and keyed by the tool's own logic, so turbo never archives it.
what a native build archives and what it keeps private

For Cargo, turbo resolves paths for the common layouts: the profile, a supported --target, a target directory inside the repo. It doesn't guess at anything else.

Nothing machine-specific should leak into the key or the bytes. A checkout path in the key means two clones never share an entry, and a build directory baked into a binary makes the entry differ by who produced it. That is why Go's GOCACHE and GOMODCACHE are left out of the key, and why -trimpath and -buildvcs=false matter to anyone who cares about identical bytes. Any extra argument on go build turns off automatic output caching, so set those flags through GOFLAGS, which is hashed.

Turbo doesn't sandbox the tool, so it trusts your declarations. Where it can't prove the key is complete, it declines to cache. Cargo library builds run uncached by default. So do Go modules without a single main, and uv builds whose backend isn't uv_build, because uv resolves those build dependencies outside uv.lock. Running every time is the honest answer when the alternative is a guess.

Two bugs in the restore path

Both were in how a local hit is restored, and both are fixed. The key and the archived bytes were fine. The layer in between was not.

The manifest records size, mtime and mode per output, or a symlink's target.If the disk still matches it, the hit is reported and the archive is never opened.#14807 · symlinksTask writes dist/link → a.txtarchived correctlymanifest writtenManifest has no entry for the linkregular files are recordedrm dist/linkLink deleted, task run againevery recorded entry still matcheshit check passesReported as a hitthe link is never restored#14872 · manifest write lostRestore finishesnew manifest written on a detached threadprocess exitsProcess exits before the writemanifest keeps the old mtimesnext runNext hit fails the shortcutevery output file is rewrittenevery runAbout 32,000 entries: 4.3 s, not 190 msnever heals; each restore loses its write
the restore manifest is a claim about the disk · two ways it was wrong

Neither produced a wrong build for long, but "a hit" is a claim about the disk as well as the hash. Whatever makes a hit fast is part of what has to be right.

Why a shared cache makes a stale hit worse

A stale entry in a local cache misleads one person, who reruns with --force. In a remote cache, everyone who computes the same hash gets the same entry.

The hash can't tell A's checkout from B'sPUT HGET HMachine Ashared/schema.json = v1the tool reads it, the keydoes not list itbuilds, hash = HRemote cacheH → result builtfrom schema v1signed or not, it isa genuine artifact for HMachine B, or CIschema.json edited to v2no input in the key movedhash = H, remote hitv1 result on diskand a local copy of itDependents hit tootheir keys contain HWhat makes it fleet-wideThe entry is keyed by H alone, so everymachine that computes H gets the same bytes.The local copy outlives the remote entry.Fixing the declaration changes H to H′ andorphans the bad entry. Until then, --forceis what overwrites it.
one undeclared input, a shared cache · how a stale entry spreads

Back to the schema file. It sits outside the crate, so the default key doesn't contain it, and a signature proves the bytes weren't changed on the way, not that the key was complete. Declare the missing input and the hash changes, which orphans the bad entry instead of overwriting it. Until then, --force re-runs the task and overwrites the entry.