← docs6 min read

9. Caching and cache trust

The cache is cook's center of gravity. Every cacheable unit (a step with at least one declared output) has exactly one content-addressed key, computed from…

manual

The cache is cook’s center of gravity. Every cacheable unit (a step with at least one declared output) has exactly one content-addressed key, computed from what you declared: the input contents, the command text or Lua chunk, the output paths, any consulted environment values, and any sealed probe values. The local cache and the shared, cross-machine store are addressed by that same key, so a teammate or a CI runner reuses your artifact if and only if it recomputes the same key.

The engine folds in only what the author declared. It infers no machine identity, toolchain, or locale of its own; that keeps keys portable by default, and means correctness rests on you declaring your real inputs. Under-declaring is the failure mode, so cook gives you tools to see the key:

  • cook why [recipe]: read-only. Prints each unit’s key and every determinant behind it, with hit/miss status. On a shared miss it diffs against what the cached artifact was built from, so a miss is always attributable.
  • cook cache verify: re-runs cached steps and reports any byte divergence under a matching key. A diagnostic for catching undeclared determinants (run it in CI on a different host), explicitly not a trust gate.

Per-step dispositions control keying and sharing. seal (the verb from Using probes) folds declared determinants into the key, and a trailing keyword on a cook step tunes how the artifact is stored and shared:

recipe misc
cook "build/scratch.txt" { ./gen > $<out> } local # cached locally, never shared
cook "build/pinned.txt" { ./gen > $<out> } pinned # fetch-only; a cold miss is an error
cook "build/blurb.txt" { llm gen > $<out> } nondet # non-reproducible; reuse the recording

Sealing composes with a top-level files declaration to solve a common bind: a recipe whose gather line is already an iteration driver (a probe fan-out) has no place to declare the other files its command reads. Name them as a files declaration and seal it: every unit’s key now carries each file’s hash, and cook why attributes a miss to the exact file that changed:

files sources
"packages/*/src/*.ts" "packages/*/tsconfig.json"
recipe typecheck
gather packages # the fan-out driver occupies this slot
seal sources # so the source files ride the key instead
cook "build/$<in.name>.stamp" { tsc -p $<in.dir> --noEmit && echo ok > $<out> }

Unannotated steps publish after a run and fetch by key before running. Restored bytes are re-fingerprinted before use, so a corrupted or tampered store entry is treated as a miss and rebuilt. --no-publish (or COOK_NO_PUBLISH=1) makes a client read-only: it still fetches, never uploads.

The shared store itself is a content-addressed directory: point [cache] cache_dir in .cook/cloud.toml at a path your team can reach and there is no server to run. See Sharing a cache across a team for the setup, the toolchain-sealing step it depends on, and the operational edges.

Keeping the store in budget

The local store grows without bound. cook cache du reports what is in it and cook cache gc evicts on demand; two more keys in [cache] make it account for itself at the end of every build:

[cache]
max_size = "20GB" # unset (the default) means no budget and no warning
auto_gc = false # the default: advisory only. true sweeps to 80% of the budget

With max_size set and auto_gc left alone, a run that published something and left the store over budget says so on stderr:

cook: warning: cache store is 23.1 GB, over the 20.0 GB budget (116%)
cook: run `cook cache gc --max-size 20GB` to reclaim 3.1 GB
cook: largest: /Cookfile::duckdb 11.0 GB (48%)

The remediation command echoes your literal verbatim, so you can paste it. The largest: line names the recipe namespace accounting for the most bytes, with its share of the store, so you know what is filling it up; objects cook can no longer attribute to a recipe count under (unattributed).

Opt in with auto_gc = true and the same run sweeps instead of warning:

cook: cache store was 23.1 GB, over the 20.0 GB budget — swept 4.2 GB (312 objects), now 18.9 GB

The sweep evicts least-recently-used artifacts down to 80% of the budget, not to the budget exactly. Sweeping to exactly max_size would leave the store one artifact from over-budget, so the next publishing run would sweep again, and the one after that. The low-water mark buys several quiet runs between sweeps.

It gets as close to that mark as it can without evicting probe values or input manifests, which the size sweep never touches: they are small and cheap to keep, and losing them costs more than it reclaims. A store whose bulk is those kinds can therefore report a sweep and still sit above the budget. Read the now figure in the sweep line against your max_size; if the gap does not close across several runs, cook cache du will show you what the sweep is declining to evict.

--no-auto-gc (or COOK_NO_AUTO_GC=1) suppresses the deletion for one run and nothing else: the warning still prints, the store is just not touched. That is the CI contract: a job stays reproducible while its operator still learns the store needs a sweep.

Three things the check will not do:

  • Fail your build. An unreadable config, a typo’d max_size, an unwalkable store, a sweep that errors: each degrades to a cook: warning: line. A typo warns rather than quietly meaning “no budget”.
  • Cost a settled build anything. It runs only after a run that published outputs, and that test comes before the config load and the store walk. An incremental build with nothing to do never looks at the store, so a store can sit over budget quietly while you are not publishing. There is no stamp file and no rate limiting either: every over-budget publishing run reports, and cook cache du asks on demand regardless.
  • Touch a server-managed cloud cache. The budget covers the local store only; a cloud backend runs its own retention.

One caveat if [cache] cache_dir points at a mount you share with your team. With auto_gc = true there, your machine sweeps a store holding your teammates’ objects, and several machines can sweep it concurrently. Touch-on-read helps (the access time lives on the shared file, so recency genuinely is shared) but the concurrent sweep is a real race. That is why auto_gc is opt-in rather than on: enabling it on a shared store is opting into that. On your own store the stakes are low, because a wrong eviction costs a rebuild and not data.