Persistent Storage¶
SQLite WAL mode¶
Relaycache uses SQLite in Write-Ahead Log (WAL) mode for its metadata database.
How WAL works¶
In standard SQLite (rollback journal mode), writes modify the database file directly and block concurrent readers. WAL inverts this:
Write: append new page versions to proxy.db-wal
(proxy.db is never modified during a write)
Read: check proxy.db-wal for recent versions first,
then fall back to proxy.db
→ readers always see a consistent committed snapshot
→ readers never block writers; writers never block readers
Checkpoint: periodically copy WAL pages back into proxy.db
→ keeps proxy.db-wal from growing indefinitely
Crash safety¶
On crash during a write: the incomplete WAL transaction is discarded on next open; proxy.db is untouched and consistent.
On crash after a commit: the committed WAL transaction is replayed on next open; no data loss for committed writes.
On clean shutdown: Relaycache calls
PRAGMA wal_checkpoint(TRUNCATE)which folds all WAL pages into proxy.db and resets the WAL file to zero bytes.
The three database files¶
File |
Purpose |
Safe to delete? |
|---|---|---|
|
Main database |
Only when stopped + WAL checkpointed |
|
Write-ahead log |
Never while running |
|
Shared memory index |
Never while running |
After a clean shutdown, proxy.db-wal and proxy.db-shm are empty or
absent. It is then safe to copy or back up proxy.db alone.
Async write pipeline¶
Relaycache never blocks a request handler on SQLite I/O. All database writes
flow through a tokio::sync::mpsc channel to a single dedicated writer task:
Request handler Writer task
│ │
│ DbOp::InsertEntry │
├──────────────────────►│ INSERT INTO cache_entries ...
│ │
│ DbOp::UpdateAccess │
├──────────────────────►│ UPDATE cache_entries SET accessed_at ...
│ │
│ DbOp::DeleteEntry │
├──────────────────────►│ DELETE FROM cache_entries WHERE key = ?
│ │
│ DbOp::DeleteBlob │
├──────────────────────►│ DELETE FROM blobs WHERE sha256 = ?
│ │ + remove file from disk
│ DbOp::Shutdown │
├──────────────────────►│ drain queue
│ PRAGMA wal_checkpoint(TRUNCATE)
│ close connection
The channel is bounded (back-pressure prevents unbounded queue growth under write storms) but large enough that normal operation never blocks.
Startup sequence¶
1. Open (or create) proxy.db with WAL mode
2. Run migrations (CREATE TABLE IF NOT EXISTS ...)
3. Scan blobs/ directory; delete any file whose SHA256 is not in blobs table
(orphans from a previous crash)
4. SELECT * FROM cache_entries JOIN blobs; load into moka
5. Start background writer task
6. Start background eviction task
7. Start HTTP listener
Shutdown sequence¶
1. Stop accepting new connections (axum graceful shutdown)
2. Wait for in-flight requests to complete
3. Send DbOp::Shutdown to writer task
4. Wait for writer task to finish (drains queue + checkpoint)
5. Exit
Blob storage layout¶
Bodies are stored as files named by their full SHA-256 hex digest, in a two-level directory structure:
cache-dir/blobs/sha256/
ab/ab3f7c9d... (64 hex chars)
ab/ab91f0e2...
cd/cd44a1b7...
The first two hex characters become a subdirectory. This bounds the number of files per directory to at most 256 subdirectories × (entries / 256) files, which keeps filesystem metadata lookups fast even with tens of thousands of blobs.
This layout is deliberately identical to Docker’s registry blob storage, making it easy to share or inspect blobs with standard registry tooling.
Reference counting and GC¶
The blobs table acts as a reference count:
-- How many entries point to this blob?
SELECT COUNT(*) FROM cache_entries WHERE blob_sha256 = ?;
A blob is deleted when this count reaches zero. This happens:
During the eviction job (after expiring entries are removed)
Immediately when a cache entry is explicitly invalidated
The GC query run by the eviction job:
SELECT sha256 FROM blobs
WHERE sha256 NOT IN (SELECT blob_sha256 FROM cache_entries);
For each returned SHA256: delete the file, then delete the blobs row.
The index on cache_entries.blob_sha256 makes this query fast even with
large tables.