Step cache internals
The step cache page covers using the cache: opting in, what is stored, the configuration. This page covers how it works, from the key of a step to a layer shared by several nodes through Redis.
Only the leading run of cacheable steps is looked up and stored: from the first step up to the first one with a task that did not opt in. Each step of that run gets a key chained from the key before it, and the first from a digest of the service itself:
SHA-256 of the configuration below.
Steps 2 and after run every time.
- Faber and the kernel (trusted)
- Outside Faber
- Starts or calls
The policy digest is the SHA-256 of a sorted-key JSON document holding:
the format tag faber-layer-v1; the CPU architecture; CACHE_NAMESPACE;
FABER_TOOLCHAINS; FABER_IMAGE_ID; the read-only paths; MEMORY_MAX,
PIDS_MAX, CPU_MAX, the wall, CPU and overall time limits, both output
limits, the workspace size and the tmpfs inode limit; the default sandbox
profile; and the sandbox policy version, which is the crate version plus a
revision bumped whenever a change to the sandbox could change a result.
A step’s canonical form is its JSON with object keys sorted at every
level: each task’s cmd, args, env, stdin, working_dir and
sandbox_profile (the server’s default already filled in), with every file
replaced by {"sha256", "executable"} of its decoded bytes. A parallel step is
an array in task order. The cache flag itself is not part of it. An inline
string, base64 content and an uploaded file_id with the same bytes give the
same key, since a file_id is the SHA-256 of the file.
What a layer holds
Section titled “What a layer holds”main.c → sha256 a41c…, 98 bytes
main → sha256 5d2e…, 16 KiB, executable
build/ → directory
- Faber and the kernel (trusted)
- Submitted code (untrusted)
- Carries data
A step is stored only if every task completed with exit code 0, outcome
exited and no truncated output, and its workspace held nothing but
directories and regular files within the size limits. Both parts are
untrusted content: whatever the task printed and wrote.
Snapshot and restore in the jailer
Section titled “Snapshot and restore in the jailer”The workspace only exists inside the sandbox, so the jailer is the one that captures it after a step and recreates it before the first step of a request that replays a prefix.
- Restore, before the first step.
- API server to Controller: manifest and blobs of the last hit
- Controller to Jailer: manifest in the job; blobs in the memfd
- Jailer: Validate the manifest, then create every directory and file under /faber the way task files are written: no links followed, owned by the task user, 0644 or 0755
- Snapshot, after each step of the cacheable run.
- Jailer: Only if the step was clean. Pass 1: walk /faber without following links, hash every regular file, refuse anything else, count files and bytes
- Jailer to Controller: snapshot header: manifest and the new blobs' hashes
- Jailer: Pass 2: stream each blob the controller does not have yet, once
- Jailer to Controller: blob frames
- Controller: Check every blob against its SHA-256; a mismatch makes the snapshot incomplete
- Store, after the sandbox is gone.
- Controller to API server: results and snapshots
- API server: For each step in order: store its blobs, then its layer; stop at the first that cannot be stored
- Faber and the kernel (trusted)
- Starts or calls
- Carries data
A snapshot is incomplete, and the step not stored, when the workspace holds a
symlink, FIFO, socket or device, a file name that is not UTF-8, more than
CACHE_MAX_LAYER_FILES entries, or more than CACHE_MAX_LAYER_BYTES of
content. The new content streamed across all snapshots of one request is
capped at CACHE_MAX_LAYER_BYTES too. The first step that cannot be stored
ends snapshotting for the request: a later layer could never be reached
without it.
/tmp is not captured, and restored files keep only the executable bit: mode
0755 or 0644, directories 0755, all owned by the task user.
Least recently used first, bounded by CACHE_MAX_BYTES and
CACHE_MAX_ENTRIES (layers and blobs together); an entry not read for
CACHE_TTL_SECS is gone.
ns:layer:<key>, ns:blob:<sha256>, ns:lock:<key>,
ns:generation. Every read refreshes the CACHE_TTL_SECS expiry.
- Faber and the kernel (trusted)
- Outside Faber
- Starts or calls
- Carries data
- Writes go to both tiers. In Redis, the blobs go first, existing ones only getting a fresh expiry, and the layer last, so that no node can see a layer whose content is not there yet.
- Blob verification. Every blob read, from either tier, is hashed and compared with its key. A mismatch deletes the blob and counts as a miss. Layers are not hashed: they are parsed, must hold clean results and a valid manifest, and are deleted otherwise. Anyone who can write to Redis can therefore make Faber return any results; keep it private to Faber.
- Lookup. Faber reads the layers of the run’s keys in order and stops at
the first miss, or when the replayed output would exceed
REQUEST_OUTPUT_LIMIT_BYTES. The workspace of the last hit is then needed in full; if any of its blobs is missing, it falls back to the hit before, and so on. - Clearing.
DELETE /api/v1/cacheempties the local tier and, with Redis, incrementsns:generationand deletes every other key of the namespace. Every node reads the generation at the start of each lookup and drops its own local tier when it has changed.
Single flight
Section titled “Single flight”When several requests need the same missing layer at the same time, one computes it and the others wait for it, once. In one process the waiting is a channel per key: the second request waits until the first has stored its layers or gone away. Across nodes it is a lock in Redis:
- Node A to Redis: SET ns:lock:k <node> NX PX <deadline>
- Redis to Node A: OK: A computes layer k
- Node B to Redis: the same SET
- Redis to Node B: nil: held by A
- Node B: Poll every 250 ms: has layer:k appeared, or lock:k gone?
- Node A: Run the sandbox
- Node A to Redis: blobs, then layer:k
- Node A to Redis: unlock, only if the lock is still A's
- Node B: Look up again: replay layer k, run the rest
- Faber and the kernel (trusted)
- Outside Faber
- Starts or calls
- Carries data
The lock value names the node (hostname, PID and start time), and the unlock is a script that deletes the key only if it still holds that value, so a slow node never releases a lock another node has taken since. Correctness never depends on the lock: if it fails or expires, the step runs twice and both nodes store the same layer.
When something fails
Section titled “When something fails”| Failure | What happens |
|---|---|
| Redis is down or slow | Each operation gets 2 seconds. A failure reads as a miss, is counted in faber_cache_shared_errors_total and logged once, and Redis is left alone for 5 seconds. Locks are skipped and the step runs |
| A layer cannot be parsed, or holds unclean results | It is deleted from both tiers and the lookup stops there |
| A blob does not match its SHA-256 | It is deleted and counts as a miss; the lookup falls back to an earlier step |
| Restoring a workspace fails in the sandbox | The request gets 500 sandbox_setup_failed, and the layer that was being restored is deleted so the next request runs the step |
| A snapshot is incomplete or a step was not clean | Nothing from that step on is stored; the request’s results are unaffected |
| The node holding a lock dies | The lock expires; waiters stop polling by then and run the step |
| The computing request goes away before storing | Its lock is released in the background, and local waiters wake up |
Where this lives in the code
Section titled “Where this lives in the code”crates/faber-api/src/step_cache.rs: the policy digest, canonical steps, key chains, lookup, in-process claims.crates/faber-api/src/handlers/execute.rs: the cacheable run,coordinate(single flight) andstore_layers.crates/faber-store/src/cache/mod.rs,local.rsandshared.rs: the tiers, blob verification, generations, locks, the Redis cooldown.crates/faber-runtime/src/runtime/snapshot.rs: the two-pass snapshot.crates/faber-runtime/src/runtime/workspace.rs: the manifest, its validation and the restore.crates/faber-runtime/src/lib.rs:SANDBOX_POLICY_VERSION.