Block Cache
Block Cache
The problem
Every read that misses memory reads a block from a file — a btree node, a filter partition, a raw block on the sstable read path. Reading the same block repeatedly from the operating system is wasteful, and the kernel’s own page cache does not help as much as it looks: a btree node arrives as bytes and has to be parsed before it is useful, and parsing it on every access is much of the cost.
So the engine keeps its own cache, and it caches parsed objects as readily as raw bytes. The consequence is that a cache hit skips both the read and the parse.
One cache, database-wide
Not per family, not per sstable. One bounded pool shared by everything, because a per-file cache would carve the memory budget by file rather than by usefulness — a hot family with few files would be capped while a cold one with many held memory it was not using.
The key is (file_id, offset): the 64-bit id of the owning file plus the block’s byte offset.
Together those name one block unambiguously across the whole database, which is what lets one
pool serve every file without collisions.
Structure
The cache is divided into independent shards, each with a fixed array of frames and a bucket table mapping keys to them:
shard 0 [ frame ][ frame ][ frame ] ... + bucket table + lock + clock hand shard 1 [ frame ][ frame ][ frame ] ... + bucket table + lock + clock hand ...Sharding is what keeps writers from serializing: a put takes only its own shard’s lock. The shard count is derived from the CPU count and the frames per shard from the configured capacity, both rounded to powers of two so the mapping is a mask rather than a division.
Frames are fixed and never freed while the cache lives. That is the central design choice and everything else follows from it: a reader that has found a frame can hold it without any risk that the frame itself disappears. Only the payload a frame points at is ever reclaimed.
A frame is in one of three states:
| State | Meaning |
|---|---|
FREE | Reusable |
LIVE | Holds a gettable entry |
DYING | Evicted, but still pinned by at least one reader |
DYING is what makes eviction non-blocking. An evictor does not wait for readers to leave; it
marks the frame and moves on, and the payload is released by whichever thread drops the last
reference.
Reads are lock-free
A get takes no lock at all:
- Probe the bucket chain for the key.
- Pin the frame with an atomic reference count.
- Recheck that the frame still holds the key that was probed.
The recheck is the whole trick. Between the probe and the pin, the frame may have been evicted and reused for a different block. Pinning first and verifying second means a reader either ends up holding what it asked for, or notices it does not and retries — and it never holds a reference to something that has been freed, because frames are not freed.
Evicted keys leave a tombstone in the bucket table rather than a hole, so a removed key cannot truncate the probe chain of a key that hashed past it.
The probe stays inside the bucket table
A bucket is a single 32-bit word holding a frame index with an 8-bit hash tag packed above it. A bucket whose tag differs cannot hold the key being looked for, so the probe rejects it from that word alone and never touches the frame.
This matters because the bucket table is dense and walked sequentially, while the frame array is large and touched at random: following a bucket to its frame is a likely cache miss, and on a loaded table most of those misses were on buckets that turn out not to match. The tag confines the common case to memory the probe is already streaming through.
The frame’s full key is still compared after a tag match, so a tag collision costs one wasted touch and never a wrong answer.
How much this is worth depends entirely on how full the table is. At a load factor around one
half it raises lookup throughput by roughly 40% at sixteen threads and 25% at one, and drops
cache_get from about 30% of CPU to 22%. On a sparsely loaded table it changes nothing at all:
probes end on their first bucket, and a bucket that matches was never going to be rejected. A
cache sized comfortably above its working set therefore sees none of this, and a cache sized just
under one sees most of it.
The tag’s bits are cut from a slice of the hash disjoint from both the low bits that pick the shard and the higher slice that picks the probe start, so the three decisions do not correlate. Two index values are reserved for empty and tombstone, which is why a shard’s frame count is capped one power of two below what the index field could otherwise address.
Eviction
Insertion may need to make room, and the sweep is a clock: each frame carries a second-chance bit set on access and cleared by the sweep. The hand advances, clearing bits and taking the first frame whose bit is already clear. Approximate LRU at a fraction of the bookkeeping.
The sweep is bounded by a fixed number of examinations — not by a multiple of the frame count, since that would tie how long the lock is held to how much the shard can hold. The clock exits at the first evictable frame, so the bound is only reached when nearly everything is pinned or freshly referenced, and the hand persists across calls: stopping short paces the work rather than abandoning it, and the next insert resumes where this one stopped.
Eviction happens under the shard lock, so an insert that must evict holds the lock for longer than one that does not. That is why waiters spin only briefly before yielding their core: on an oversubscribed machine, spinning through another thread’s clock sweep wastes exactly the capacity the sweep is competing for.
Exactly-once reclamation
A payload must be released exactly once, and the thread that should do it is not known in advance: it may be the evictor, or a reader that was still inside when eviction happened.
The rule is that whoever drops the last reference reclaims. There is no separate reclamation pass, no epoch, no deferred list — the same reference-counting discipline the rest of the engine uses.
Each entry carries its own reclaim function, supplied at insert. That is what lets one cache hold both parsed btree nodes and raw byte blocks: the cache does not know what a payload is, only how to release it.
What it costs and what it reports
The cache is a pure optimisation — every entry can be dropped and re-read. Nothing depends on it for correctness, which is why it can evict without coordinating with readers.
tidesdb_get_cache_stats reports hits,
misses, hit rate, residency, and shard count. The hit rate is cumulative since open, so it
moves slowly; compare deltas between samples rather than absolute values when tuning.
Invariants
| Invariant | Why |
|---|---|
| Frames are never freed while the cache lives | A reader can pin without the frame vanishing under it |
| The frame count follows the byte budget | A frame ceiling that binds first shrinks the cache silently, while the configured budget still reports as given |
| The sweep’s bound is absolute, not a multiple of the frame count | It limits how long the shard lock is held, which must not grow with how much the shard holds |
| Pin, then recheck the key | The frame may have been reused between probe and pin |
| An evicted key leaves a tombstone, not a hole | Otherwise it truncates the probe chain of later keys |
| A payload is reclaimed exactly once, by the last reference dropped | Evictor and reader race; neither may assume it is last |
| The clock sweep is bounded | An all-pinned shard must not spin forever |
| Nothing depends on the cache for correctness | It must remain free to evict at any moment |