Step cache
The step cache lets Faber skip work it has already done. The typical case is a judge that compiles the same submission once and then runs it against many inputs, possibly across many requests and many Faber instances: with the compile step cached, later requests start with the compiled binary already in the workspace.
The cache is off by default. The operator turns it on with
CACHE_ENABLED=true; clients then opt individual steps in.
What gets cached
Section titled “What gets cached”A cached layer is one step’s outcome:
- the step’s results, exactly as they were returned, including the
original
stats; - the workspace manifest after the step: every regular file and directory
under
/faber, with its path, SHA-256, size and executable bit, plus the file contents in a content-addressed blob store.
When a request hits a layer, Faber returns the stored results with
cached: true and recreates the workspace from the manifest before running
the next step. The next step sees the same files, with the same executable
bits, owned by the task user.
Also not captured: file ownership (restored files belong to the task user),
timestamps, and permission bits other than executable (0755) versus not
(0644).
Opting in
Section titled “Opting in”A step is cacheable when:
- the server cache is enabled (
CACHE_ENABLED=true), and - every task in the step is cacheable: its own
cachefield if set, otherwise the request’scachefield (defaultfalse).
{ "steps": [ { "cmd": "gcc", "args": ["-O2", "-o", "main", "main.c"], "files": { "main.c": "..." }, "cache": true }, [ { "cmd": "./main", "stdin": "1 2\n", "sandbox_profile": "native_v2" }, { "cmd": "./main", "stdin": "40 2\n", "sandbox_profile": "native_v2" } ] ]}Here the compile step is cacheable and the test step is not, so tests always run. Legacy bare-array requests treat every step as cacheable when the server cache is on.
Mark a task cache: true only if it is deterministic: the same inputs
always produce the same files and output. Do not cache steps whose results
depend on the time, randomness, or anything else outside the request.
Only a leading run is cached
Section titled “Only a leading run is cached”Faber looks up and stores only the leading run of cacheable steps: from the first step up to, but not including, the first step that is not cacheable. Nothing after that point is looked up or stored, even if it is marked cacheable.
A step after a non-cacheable step depends on whatever that step did, which may differ between runs. Caching it would replay stale state.
| Step | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| Marked cacheable | yes | yes | no | yes |
| Looked up and stored | yes | yes | no | no, it comes after step 2 |
What is stored
Section titled “What is stored”A step in the leading run is stored only if:
- every task completed with
exit_code0 and outcomeexited; - no output was truncated;
- the workspace after the step contains only regular files and directories (no symlinks, FIFOs, sockets or device nodes);
- the workspace holds at most
CACHE_MAX_LAYER_BYTES(64 MiB by default) in at mostCACHE_MAX_LAYER_FILESfiles (4096 by default).
A step that fails any of these runs normally and is simply not stored. A compile error is never cached, so fixing the code and resubmitting just works.
Layers are addressed by a chain of SHA-256 keys:
policy = sha256("faber-layer-v1" ‖ arch ‖ CACHE_NAMESPACE ‖ FABER_TOOLCHAINS ‖ FABER_IMAGE_ID ‖ read-only paths ‖ every effective limit ‖ default sandbox profile ‖ sandbox policy version)
key_0 = sha256(policy ‖ canonical(step_0))key_i = sha256(key_(i-1) ‖ canonical(step_i))canonical(step) is the step as sorted-key JSON, after the server’s default
sandbox profile has been filled in, with every file replaced by the SHA-256
and executable bit of its decoded bytes.
What follows from this:
- Everything about a step is part of its key: command, arguments, environment, stdin, working directory, profile, and the content of every input file. Changing any of it is a miss.
- A key depends on every step before it. The same
./mainstep after a different compile step has a different key. - Inline and uploaded copies of the same bytes hash the same. A file sent
as a string, as base64, or as a
file_idreference produces the same key if the bytes and executable bit match. - Server configuration is part of the key. Changing a limit, the
read-only paths, the default profile or the toolchains starts a fresh set
of keys rather than replaying layers produced under different conditions.
Set
FABER_IMAGE_IDto something that changes with the image (its digest or build ID) so a rebuilt image with different compilers never reuses old layers. CACHE_NAMESPACEseparates caches that share one Redis.
Lookup and replay
Section titled “Lookup and replay”For each request Faber:
- computes the keys of the leading cacheable run;
- finds the longest prefix of those keys that is in the cache;
- returns the results of those steps with
cached: true; - starts the sandbox with the workspace of the last hit restored;
- runs the remaining steps, and after each one that is still in the cacheable run, takes a workspace snapshot and stores the layer.
The response’s cache object reports what happened:
"cache": { "enabled": true, "hits": 1, "stored": 0 }hits counts steps replayed, stored counts steps this request executed and
wrote to the cache.
Concurrent identical requests. Within one Faber process, identical
cacheable prefixes in flight at the same time share one execution. Across
instances sharing Redis, Faber takes a best-effort lock
(SET {ns}:lock:{key} <node> NX PX <ms>); other instances wait for the layer
until the lock expires, then run the step themselves. Correctness never
depends on the lock; at worst a step runs twice.
Storage tiers
Section titled “Storage tiers”| Tier | Always on | Bounded by |
|---|---|---|
| Local | Yes, in every Faber process | CACHE_MAX_BYTES, CACHE_MAX_ENTRIES (least recently used is evicted) and CACHE_TTL_SECS |
| Shared | With CACHE_BACKEND=redis | CACHE_TTL_SECS per key, refreshed on every hit; Redis’s own memory policy |
The shared tier lets several Faber instances (for example, the pods of a Kubernetes Deployment) reuse each other’s layers. Its layout:
| Key | Value |
|---|---|
{namespace}:layer:{key} | The layer: results and workspace manifest, as JSON |
{namespace}:blob:{sha256} | One file’s bytes |
{namespace}:lock:{key} | The single-flight lock, while a layer is being produced |
Every blob’s SHA-256 is checked when it is read. A mismatch is treated as a miss and the entry is deleted.
A failing or unreachable Redis never fails a request: the lookup is treated as a miss, the error is logged and counted in the metrics, and the step runs.
Running a cluster with a shared cache shows a complete setup.
Clearing the cache
Section titled “Clearing the cache”curl -X DELETE http://localhost:3000/api/v1/cache -H "Authorization: Bearer $API_KEY"{ "cleared": 12 }This drops the local tier and, with Redis, every key in the configured
CACHE_NAMESPACE, so it affects every instance sharing that namespace. When
the cache is disabled the call answers 409 (cache_disabled).
Things to keep in mind
Section titled “Things to keep in mind”- Stats are historical. A replayed result carries the
statsof the run that produced it. If you measure time or memory, measure on steps that are not cached. - Hits are observable. All API key holders share one cache. A request can
tell from
cache.hits(or from timing) that an identical step ran before. Only identical inputs hit, so contents do not leak, but the fact that someone compiled the same file does. - Redis holds workspace contents. Treat it as sensitive: keep it on a private network, require authentication, and do not share it with other applications.
- Nothing outside the workspace is restored. A step that installs into
$HOMEor writes caches under/tmpwill not see them after a replay.
Configuration
Section titled “Configuration”| Variable | Default | Meaning |
|---|---|---|
CACHE_ENABLED | false | Turns the step cache on |
CACHE_BACKEND | memory | memory (local tier only) or redis (local plus shared) |
CACHE_REDIS_URL | none | Redis URL, required with CACHE_BACKEND=redis |
CACHE_NAMESPACE | default | Key prefix in Redis; also part of every layer key |
CACHE_TTL_SECS | 300 | Lifetime of a layer, refreshed on hit |
CACHE_MAX_BYTES | 64 MiB | Bytes held by the local tier |
CACHE_MAX_ENTRIES | 1024 | Layers and blobs held by the local tier |
CACHE_MAX_LAYER_BYTES | 64 MiB | Largest workspace a layer may capture |
CACHE_MAX_LAYER_FILES | 4096 | Most files a layer may capture |
FABER_TOOLCHAINS | empty | Toolchains in the image, part of every key |
FABER_IMAGE_ID | empty | Image identity, part of every key |
The configuration reference has the details.
Go deeper
Section titled “Go deeper”Step cache internals: the key chain, the snapshot inside the sandbox, the two tiers, single flight across instances, and what happens when something fails.