Caching Model¶
Two-layer architecture¶
Relaycache separates the cache into two layers:
┌─────────────────────────────────────────────────────┐
│ In-memory index (moka LRU) │
│ key → CacheEntry { etag, last_modified, headers, │
│ blob_sha256 } │
│ Fast O(1) lookup; bounded by --cache-max-entries │
└──────────────────────────┬──────────────────────────┘
│ blob_sha256
┌──────────────────────────▼──────────────────────────┐
│ Disk blob store │
│ cache-dir/blobs/sha256/ab/ab3f7c... │
│ Files named by SHA-256 digest of content │
│ Two-level directory (first 2 hex chars) │
└─────────────────────────────────────────────────────┘
In-memory index holds only metadata: validators, headers to replay, and the blob reference. This is small (a few hundred bytes per entry) and always fits in RAM.
Disk blobs hold the actual response bodies. They are never loaded into RAM except when being served. Large responses (e.g. Docker layer blobs of several hundred MB) are streamed from disk to the client without buffering.
Persistence¶
The in-memory index is backed by a SQLite database (proxy.db in
--cache-dir). On startup, all rows are loaded into moka. During operation,
writes flow through an async background channel to a dedicated writer task —
request handling never blocks on SQLite I/O.
On clean shutdown:
The writer task drains its queue
PRAGMA wal_checkpoint(TRUNCATE)is called to flush WAL to the main fileThe process exits
On restart after a crash:
SQLite replays any committed WAL transactions automatically
Relaycache scans
blobs/sha256/and removes any blob files not recorded in theblobstable (orphans from an interrupted write)
Blob deduplication¶
Bodies are stored by SHA-256 digest. If two different cache entries happen
to have identical bodies (e.g. two Vary variants that return the same bytes
for different Accept values), they naturally share the same blob file
without any explicit logic. The blobs table reference-counts this:
-- How many entries point to a given blob?
SELECT COUNT(*) FROM cache_entries WHERE blob_sha256 = ?;
When a cache entry is evicted, its blob is deleted only if no other entry references it.
Eviction¶
The background eviction job runs every --eviction-interval. It:
Finds entries where
accessed_at < now - entry_ttlRemoves them from moka and queues DB deletes
After all entry deletes are committed, finds orphaned blobs:
SELECT sha256 FROM blobs WHERE sha256 NOT IN (SELECT blob_sha256 FROM cache_entries);
Deletes the blob files and their
blobstable rows
TTL is based on accessed_at (LRU-style): accessing a cached entry resets
its TTL. This keeps frequently-used entries alive indefinitely while
evicting stale ones.
Database schema¶
-- One row per unique body (reference-counted).
CREATE TABLE blobs (
sha256 TEXT PRIMARY KEY,
size_bytes INTEGER NOT NULL
);
-- One row per cached resource variant.
-- Never stores Authorization, WWW-Authenticate, Set-Cookie,
-- Proxy-Authorization, Proxy-Authenticate, or hop-by-hop headers.
CREATE TABLE cache_entries (
key TEXT PRIMARY KEY,
blob_sha256 TEXT NOT NULL REFERENCES blobs(sha256),
etag TEXT,
last_modified TEXT,
headers_json TEXT NOT NULL, -- safe response headers only (see above)
created_at INTEGER NOT NULL,
accessed_at INTEGER NOT NULL
);
CREATE INDEX idx_entries_accessed ON cache_entries(accessed_at);
CREATE INDEX idx_entries_blob ON cache_entries(blob_sha256);
Why store headers_json?¶
When serving a cached body as 200 OK, Relaycache must replay the original
response headers to the client: Content-Type, ETag, Last-Modified,
Cache-Control, Docker-Content-Digest, etc. Without these the client
receives a body with no useful metadata.
Headers explicitly excluded from storage:
Header |
Reason |
|---|---|
|
Request header; should never appear in responses, but excluded defensively |
|
Tells clients how to authenticate; varies per-request, not a body property |
|
Session tokens; replaying to a different user is a security violation |
|
Request credential header |
|
Proxy auth challenge |
|
Hop-by-hop; connection-specific |
|
Would be stale on replay |