Share a cache across a team¶
The problem. Five engineers and CI all run the same behavioural suite. Every one of them pays separately for identical answers, and a full run is slow enough that people stop doing it before merging.
The shape. A content-addressed cache with a shared tier behind the local disk. The first person to ask a question pays; everyone else replays.
Why this works at all¶
A key is the SHA-256 of the request and nothing else — one rule, documented once. That is the whole reason a shared cache is possible.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Not paying twice for the same answer.
#
# Every outgoing request is cached, content-addressed, on by default — a
# provider call, the LLM grader, an embedding, an exec grader alike. Run this
# suite twice and the second run does no work at all: it prints the same report
# from the cache, instantly and for free.
#
# The rule the key follows is one sentence:
#
# Hash what is sent.
#
# The key is the SHA-256 of the redacted request itself, plus the trial index
# (`--repeat`), plus any `cache_salt` in scope. Nothing about your machine, your
# binary, or your credentials is in it — no path, no mtime, no digest of the
# program's own bytes.
#
# That is what makes a cache shareable. A key that varied by machine could not
# be reused by anyone else, which quietly turns a shared cache into an expensive
# local disk.
#
# Three consequences worth knowing:
# * one entry per key, immutable — first write wins, on every backend
# * errors are NEVER cached; only successful responses are
# * `latency` assertions bypass the cache entirely, because a replayed
# response has no honest latency to report — and under `--cache-only` such
# a case is refused outright rather than called live
#
# Run it twice and watch the second run:
# domarinn run examples/21-caching-basics
# domarinn run examples/21-caching-basics # every cell a cache hit
#
# To force real work: --no-cache
# To re-ask only the grader: --no-grader-cache
# To require the cache: --cache-only (a miss becomes an infra error, exit 3)
version: 1
project: examples
suite: caching-basics
providers:
- id: assistant
type: exec
command: ["python3", "../echo-provider.py"]
# An exec provider's key hashes what it SENDS — the command, the args, the
# document on the child's stdin — and says nothing about the program's
# bytes, so a rebuild does NOT invalidate anything on its own. This salt is
# the version pin that does. See example 22.
cache_salt: "dev"
cache:
# `disk` (the default) stores under `.domarinn/cache` beside this suite.
# `layered` puts a shared tier behind that local one — S3 when a `cache.s3`
# block is set, else the results server. Those two are the whole list;
# `http` and `s3` still parse as deprecated aliases for `layered`. Note the
# config names only the KIND — the URL and credentials come from the
# environment, so a suite is safe to commit.
backend: disk
tests:
- id: summary/short
vars:
user_input: "The meeting moved to Thursday at 10."
assert:
- type: icontains
value: "thursday"
- id: summary/long
vars:
user_input: "Deployment is blocked on the migration; expect Friday."
assert:
- type: icontains
value: "friday"
- id: summary/empty-ish
vars:
user_input: "noted"
assert:
- type: length
min: 1
1. Pick a backend¶
backend |
Shared tier | Use when |
|---|---|---|
disk |
none | Solo work. The default. |
layered |
S3 when cache.s3 is set, else the results server's /api/v1/cache |
Anything shared. |
Those are the two. http and s3 still parse as deprecated aliases for layered and warn at startup, but they name one tier outright instead of letting cache.s3 choose — so backend: s3 with no cache.s3 block degrades to local disk alone rather than falling back to the server. See caching.md.
A remote always keeps the local tier in front, so a warm local hit never touches the network.
The config names only the kind
No URL, no credentials — those come from the environment (DOMARINN_SERVER_URL, DOMARINN_TOKEN, or the AWS credential chain). That is what makes a suite safe to commit, and it means the same file works locally and in CI with no branching.
If the credentials are missing, domarinn falls back to local disk with a warning rather than failing the run. Convenient, and worth knowing about: a misconfigured CI job will look like it is working while paying full price. Check for the warning.
What has to match across environments¶
For sharing to hold, keep whatever changes the request identical everywhere: a different model, endpoint, params, header value or cache_salt is a different request, and therefore a different entry nobody else hits. Cosmetic differences do not count — a base_url with and without a trailing slash names one endpoint and keys one way.
Nothing about the environment has to match. Different checkout paths, different file timestamps, a binary rebuilt from the same commit, a different working directory, unrelated exported variables, two teammates holding different API keys — none of these move a key, by construction. That is what makes a shared backend worth having, and crates/domarinn-core/tests/cache_portability.rs pins each one so it stays true.
2. Salt at the right granularity¶
This is the part that decides whether a shared cache stays useful or gets thrown away weekly.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Busting the cache on purpose, at the right granularity.
#
# The cache key is the request, and a request only carries what domarinn can
# SEE. When the system under test loads its own content across a process
# boundary — prompts from a registry, rules from a database, a model baked into
# a binary — domarinn never sees that content, so editing it changes nothing
# about the request and the cache keeps answering with yesterday's results.
#
# `cache_salt` is the lever, and it exists at two levels because it is really
# two different problems. A salt joins the key as its own member, only when it
# is set, and is never sent to the provider.
#
# PROVIDER-LEVEL: a coarse "same build?" pin. Bump it when the program itself
# changes behaviour. Do NOT make it a content digest of everything the program
# reads — that throws the whole cache away on any edit, which is the outcome the
# per-case salt exists to avoid.
#
# PER-CASE: a content digest of just the thing THIS case exercises. Edit one
# prompt and only the cases that use it re-run. This is what keeps a large suite
# affordable while staying honest.
#
# `$digest:` computes that digest for you. The glob is rendered against the
# case's own vars, matched files are hashed in sorted order WITH their relative
# paths, and a glob matching nothing is an error rather than an empty digest —
# because an empty digest would silently mean "never bust".
#
# Run: domarinn run examples/22-cache-salts
version: 1
project: examples
suite: cache-salts
providers:
- id: prompt-runner
type: exec
# The request carries only a prompt_id; the program reads the prompt file
# itself. domarinn therefore cannot see a prompt edit — hence the salts.
command: ["python3", "./run-prompt.py"]
# Coarse version pin. A commit SHA or a release tag is the usual value.
# Bump when the program's own logic changes.
cache_salt: "runner-v3"
tests:
- id: prompts/greeting
vars:
prompt_id: greeting
user_message: "hello there"
# Digest of just this case's prompt. Editing prompts/greeting.md busts this
# case and nothing else.
cache_salt: "$digest: prompts/{{ prompt_id }}.md"
assert:
- type: icontains
value: "welcome"
- id: prompts/escalation
vars:
prompt_id: escalation
user_message: "this is unacceptable"
cache_salt: "$digest: prompts/{{ prompt_id }}.md"
assert:
- type: icontains
value: "specialist"
# A case may digest more than one file — every prompt the flow touches.
- id: prompts/whole-flow
vars:
prompt_id: greeting
user_message: "hi"
cache_salt: "$digest: prompts/*.md"
assert:
- type: length
min: 1
Two levels, two different jobs:
- Provider-level
cache_salt— a coarse "same build?" pin. Bump it when the program's own logic changes. A commit SHA or a release tag. - Per-case
cache_salt: "$digest: …"— a content digest of just what this case exercises.
Do not make the provider-level salt a content digest of everything your program reads. It works, and it discards the entire cache on any edit — which is exactly the outcome the per-case salt exists to prevent. With both in place, editing one prompt re-runs the handful of cases that use it and replays the rest.
The theory — why a salt is a version pin rather than an entry ticket, and how $digest: resolves — is in caching.md.
3. Know what is and is not cached¶
- One entry per key, immutable. First write wins, on every backend — so concurrent writers are race-free by construction.
- Errors are never cached. Only successful responses.
- Grader calls are cached too, as requests like any other: the grader's HTTP call, an embedding, an
execgrader's round-trip. A warm run re-pays neither the provider nor the grader.--no-grader-cachere-grades while still replaying provider responses, which is what you want while iterating on a rubric. - A
thresholdis not in the key. It is applied on read, so editing a threshold re-scores instantly instead of re-paying the grader. - Pricing is not in the key either.
cost_usdis recomputed on every hit, so correcting a rate re-prices history rather than discarding it. latencyassertions bypass the cache entirely — a replayed response has no honest latency — and under--cache-onlythe case is refused rather than called live.
The full list is in caching.md.
4. Verify it is actually shared¶
The second run should report every cell as a cache hit. If it does not, something in the suite varies between runs — a now() in a var, a $digest: glob matching a file that is being rewritten, or a salt containing a timestamp.
See also¶
- Caching — the full key semantics and backend details.
- Example 21 and 22.
- Server — running the shared tier.