Cache internals
This page documents the exact mechanics of the on-disk cache: what goes into a
key, how entries are stored and verified, and how eviction works. It is written
for a reader who needs to reason about the cache directly — debugging a phantom
hit, auditing what leaks into a key, or planning a remote backend. Storage is a
trait (CacheStore), and LocalStore is the only implementation today, with a
future remote backend (Lattice Cloud) in mind. If you just want to know what
Lattice caches and how to control it, see Caching.
Cache key composition
A task’s cache key is a 64-character lowercase hex SHA-256 digest, computed by
compute_key. The hasher consumes fields in this fixed order:
lattice_version— the running Lattice version.task— the task name (e.g.build).command— the fully resolved shell command for this task in this workspace.toolchain_identity— the identity string of the workspace’s resolved toolchains (empty if the workspace declares none).env.name/env.value— one pair per name listed in the task’senv, resolved from the process environment, sorted by name.input.path/input.content— one pair per file matched by the task’sinputsglobs (after removing anything matched byignore), sorted by path relative to the workspace, with the file’s full contents hashed in.lockfile.name/lockfile.content— one pair for each tool-unique lockfile that exists in the workspace, checked in this fixed order:package-lock.json,yarn.lock,pnpm-lock.yaml,bun.lockb,bun.lock,Cargo.lock,go.sum,poetry.lock,uv.lock,Gemfile.lock.
Every list (env pairs, input files, lockfiles present) is sorted before
hashing, so the key does not depend on filesystem iteration order or on the
order fields were declared in lattice.json.
Domain separation and length prefixing
Each field above is written into the hasher as a tag plus a payload, and both are length-prefixed:
u64_le(len(tag)) ++ tag_bytes ++ u64_le(len(payload)) ++ payload_bytes
This is why two different input sets can never collide by concatenation. Without
length prefixes, hashing task="ab" next to command="c" would produce the
same bytes as task="a" next to command="bc". With an explicit length in
front of every tag and every payload, a field’s boundary is unambiguous
regardless of what bytes it contains — a byte sequence that looks like the next
tag is just payload, because the reader already knows how many bytes to
consume. The tag itself ("task", "input.path", "lockfile.content", …)
further guarantees a value hashed under one field name can never be mistaken
for a value under another.
On-disk layout
Every cache entry lives under the configured cache directory (default
.lattice/cache, overridable with settings.cacheDir) as two files sharing
the key as their stem:
.lattice/cache/<key>.tar.gz the artifact: gzip-compressed tar of outputs
.lattice/cache/<key>.meta.json the metadata: everything needed to verify and restore
There is no subdirectory nesting and no sharding by key prefix — the cache
directory is a flat list of <key>.tar.gz / <key>.meta.json pairs.
The metadata file
CacheMeta is serialized as pretty-printed, camelCase JSON. This is a real
file written by running a task with outputs: ["dist/**"]:
{
"key": "b6961d5e2c3f67b5c29bda672621ad835cb286a8da6bd82a6fef01e9cfb372c5",
"task": "build",
"workspace": "app",
"durationMs": 5,
"lastUsed": "2026-07-29T01:26:44.096642Z",
"env": {},
"outputDigest": "7b9b704caefd130afd5440ce20ec161676fe24497cf344eb8473f06cf2984b75"
}
Fields:
| Field | Meaning |
|---|---|
key |
The cache key. Also the filename stem for both files on disk. |
task |
The task name, for humans reading the cache directory. |
workspace |
The workspace name, for humans reading the cache directory. |
durationMs |
How long the task took to run when this entry was written. |
lastUsed |
RFC 3339 timestamp, updated on every write and every hit. Drives eviction order — see below. |
env |
The resolved (name, value) pairs for the task’s declared env, captured so a cache hit can report what was in effect. |
outputDigest |
SHA-256 hex of the .tar.gz bytes, recorded when the artifact is written and checked on every lookup. |
Note that key and outputDigest are both 64-character hex SHA-256 digests,
but they hash different things: key is the identity computed by
compute_key from the fields above; outputDigest is a digest of the stored
artifact’s bytes, computed after the tarball is written.
Archive format and the output digest
store collects every file matched by the task’s outputs globs (a directory
pattern like dist/** captures every file beneath it) and writes them,
relative to the workspace root, into a gzip-compressed tar archive at
<key>.tar.gz. Files are collected and added in sorted order, so the archive
is reproducible for identical outputs. Once the archive is finished, its bytes
are hashed and the result is written into meta.outputDigest before the
metadata file is saved. restore reverses this: it opens the tarball and
unpacks it directly into the target workspace path.
The lookup sequence
lookup(key) runs these checks in order. Any failure is a miss — never an
error and never a stale hit:
- Read and parse
<key>.meta.json. Missing file, or a file that fails to parse as JSON → miss. - Check
<key>.tar.gzexists. Meta present but no tarball → miss. - Open the tarball and compute its SHA-256. Unreadable file → miss.
- Compare that digest against
meta.outputDigest. Mismatch → miss.
Only when all four checks pass does lookup return Some(CacheEntry). This is
the guarantee worth internalizing: a lookup is a hit only if the meta parses,
the tarball opens, and its digest matches the recorded outputDigest. A
truncated download, a manually edited archive, or a half-written file from a
crashed process is a miss, not a false hit — the runner falls through and reruns
the task rather than restoring corrupt output.
lastUsed bookkeeping and eviction order
lastUsed is set when an entry is stored and refreshed by touch, which the
runner calls on every cache hit after a successful restore. It is the only
field touch changes; outputDigest and the rest of the metadata are left
alone.
prune(max_bytes) enforces settings.maxCacheSize (or --max-size on
lattice prune) by:
- Scanning the cache directory for every
<key>.meta.json, reading each entry’slastUsedand combined on-disk size (.tar.gz+.meta.json). - If the total is already at or under
max_bytes, doing nothing. - Otherwise sorting entries by
lastUsedascending and deleting the oldest first — removing both files for each evicted key — until the running total is at or undermax_bytes.
This is strict least-recently-used eviction: an entry that is hit often stays,
however old it is, because every hit advances lastUsed; an entry nobody has
restored since it was written is the first to go. A missing cache directory is
not an error — prune reports zero entries removed and zero bytes freed. If
neither --max-size nor settings.maxCacheSize is set, lattice prune fails
outright rather than picking an arbitrary limit.
What is deliberately not part of the key
The key is computed from the fields listed above and nothing else. Notably absent:
- The task’s
outputspatterns. Onlyinputs(and lockfiles) are hashed; changing what a task declares as output does not change its identity, and does not invalidate an existing cache entry for the same inputs. - Files outside
inputs. A file the workspace contains but that noinputsglob matches has no effect on the key, even if the task’s command happens to read it. If a task depends on a file, it belongs ininputs. - Environment variables not named in the task’s
envlist. Only the variables a task explicitly declares are resolved and hashed; anything else in the ambient environment is invisible to the cache, by design — otherwise every shell’s ambient state would perturb every key. - Wall-clock time, hostname, or working directory path. The key is a pure function of the fields above, which is what makes it reproducible across machines and across clones of the same repository.
The consequence in each case is the same shape: anything not in the key can
change silently without a cache miss. Getting the task’s inputs (and env)
list right is what keeps the cache honest — an incomplete inputs list is the
usual cause of a stale hit that should have been a miss.