Skip to content

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.

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).

A step is cacheable when:

  1. the server cache is enabled (CACHE_ENABLED=true), and
  2. every task in the step is cacheable: its own cache field if set, otherwise the request’s cache field (default false).
{
"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.

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.

Step0123
Marked cacheableyesyesnoyes
Looked up and storedyesyesnono, it comes after step 2

A step in the leading run is stored only if:

  • every task completed with exit_code 0 and outcome exited;
  • 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 most CACHE_MAX_LAYER_FILES files (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 ./main step 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_id reference 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_ID to something that changes with the image (its digest or build ID) so a rebuilt image with different compilers never reuses old layers.
  • CACHE_NAMESPACE separates caches that share one Redis.

For each request Faber:

  1. computes the keys of the leading cacheable run;
  2. finds the longest prefix of those keys that is in the cache;
  3. returns the results of those steps with cached: true;
  4. starts the sandbox with the workspace of the last hit restored;
  5. 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.

TierAlways onBounded by
LocalYes, in every Faber processCACHE_MAX_BYTES, CACHE_MAX_ENTRIES (least recently used is evicted) and CACHE_TTL_SECS
SharedWith CACHE_BACKEND=redisCACHE_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:

KeyValue
{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.

Terminal window
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).

  • Stats are historical. A replayed result carries the stats of 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 $HOME or writes caches under /tmp will not see them after a replay.
VariableDefaultMeaning
CACHE_ENABLEDfalseTurns the step cache on
CACHE_BACKENDmemorymemory (local tier only) or redis (local plus shared)
CACHE_REDIS_URLnoneRedis URL, required with CACHE_BACKEND=redis
CACHE_NAMESPACEdefaultKey prefix in Redis; also part of every layer key
CACHE_TTL_SECS300Lifetime of a layer, refreshed on hit
CACHE_MAX_BYTES64 MiBBytes held by the local tier
CACHE_MAX_ENTRIES1024Layers and blobs held by the local tier
CACHE_MAX_LAYER_BYTES64 MiBLargest workspace a layer may capture
CACHE_MAX_LAYER_FILES4096Most files a layer may capture
FABER_TOOLCHAINSemptyToolchains in the image, part of every key
FABER_IMAGE_IDemptyImage identity, part of every key

The configuration reference has the details.

Step cache internals: the key chain, the snapshot inside the sandbox, the two tiers, single flight across instances, and what happens when something fails.