Skip to main content

cqlite_core/storage/cache/
mod.rs

1//! Shared, bytes-bounded, sharded decompressed-chunk cache (issue #1567, Epic B/B1).
2//!
3//! A cache of *decompressed* SSTable compression chunks keyed by authoritative
4//! `(sstable identity, chunk index)`. It is the single biggest lever for
5//! repeated-read latency identified by the July 2026 read-path audit: every
6//! wired read site consults it before reading+decompressing, so a repeat read of
7//! a resident chunk is a refcount bump instead of a disk read + decompress.
8//!
9//! # Design (owner decision #1, LOCKED — see
10//! `openspec/changes/decompressed-chunk-cache/design.md`)
11//!
12//! - **Value = `Arc<[u8]>`** (D3): a hit is `Arc::clone` (refcount bump), never a
13//!   chunk-sized memcpy. Insert converts the decompressed `Vec<u8>` once.
14//! - **Bytes-bounded, not entry-count** (spec R1): each entry is weighed by its
15//!   decompressed length; after an insert the owning shard evicts LRU entries
16//!   until it is within its byte budget. A single entry larger than the budget is
17//!   retained (we never evict below one live entry) — the read path must always be
18//!   able to return the chunk it just produced.
19//! - **Hand-sharded `Mutex<LruCache>`** (D2): `shards.len()` is a power of two.
20//!   The hit path locks exactly ONE shard and calls `LruCache::get` (which mutates
21//!   recency — hence a `Mutex`, not an `RwLock`; but sharded so contention is
22//!   `1/N`, never a single process-wide lock). Reuses the tested `lru` crate
23//!   internals; no new external dependency.
24//! - **Poison-tolerant** (D2): every lock is taken with
25//!   `lock().unwrap_or_else(|e| e.into_inner())` so one panicking thread cannot
26//!   turn the cache into a panic-for-everyone. No `unwrap()`/`expect()` here.
27//! - **No-heuristics** (mandate #28): keys are `(u64 sstable id, u64 chunk index)`
28//!   derived from authoritative reader identity + chunk offsets — never inferred
29//!   from decompressed byte content.
30//!
31//! The default is a `Box<[Mutex<Shard>]>` rather than a fixed `[Mutex<Shard>; N]`
32//! array so the shard count is a constructor parameter: production uses
33//! [`DEFAULT_SHARDS`], while unit tests use a single shard for deterministic
34//! eviction ordering. Both remain power-of-two hand-sharded `Mutex<LruCache>` —
35//! the design intent is preserved.
36
37use lru::LruCache;
38use std::hash::{Hash, Hasher};
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::{Arc, Mutex};
41
42/// Default shard count (power of two). Production readers use this via
43/// [`DecompressedChunkCache::with_budget_bytes`].
44pub const DEFAULT_SHARDS: usize = 16;
45
46/// Default total byte budget when a cache is constructed without an explicit
47/// budget (mirrors `config.memory.block_cache.max_size`'s 256 MiB default).
48pub const DEFAULT_BUDGET_BYTES: usize = 256 * 1024 * 1024;
49
50/// Authoritative cache key: an SSTable identity hash and a chunk discriminator.
51///
52/// `chunk_index` carries whichever authoritative offset the wiring site uses —
53/// a real chunk index (windowed scan, BTI target chunk) or an index-resolved
54/// `block_offset` (BIG point read). Sites keep separate key namespaces by folding
55/// a site salt into `sstable` (design D4), so numerically-overlapping values from
56/// different sites never collide.
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
58pub struct ChunkKey {
59    /// Stable hash of the SSTable's `file_path` (+ generation), optionally XORed
60    /// with a per-site namespace salt.
61    pub sstable: u64,
62    /// Authoritative chunk index or index-resolved offset within `sstable`.
63    pub chunk_index: u64,
64    /// Extra discriminant for a size-dependent range read. The chunk-index sites
65    /// (BTI, windowed scan) decompress a whole compression chunk whose bytes are
66    /// fully determined by `chunk_index`, so they use `aux = 0`. The BIG
67    /// point-read path (`get_cached_data`) reads an index-resolved
68    /// `(block_offset, size)` byte range whose decompressed content depends on
69    /// BOTH the offset AND the length: two reads at the same `block_offset` with
70    /// different `size` decompress different input and MUST NOT alias. `aux`
71    /// carries `size` for that path so the key is complete (roborev #1567), and
72    /// because `(u64 offset, u32 size)` cannot be packed into one `u64` without
73    /// loss, `aux` is a first-class key field, not folded into `chunk_index`.
74    pub aux: u64,
75}
76
77impl ChunkKey {
78    /// Construct a key from an sstable identity hash and a chunk discriminator
79    /// (whole-chunk sites; `aux = 0`).
80    #[inline]
81    pub fn new(sstable: u64, chunk_index: u64) -> Self {
82        Self {
83            sstable,
84            chunk_index,
85            aux: 0,
86        }
87    }
88
89    /// Construct a key with an extra discriminant (`aux`) for a size-dependent
90    /// range read (the BIG point-read path keys `aux = size`).
91    #[inline]
92    pub fn with_aux(sstable: u64, chunk_index: u64, aux: u64) -> Self {
93        Self {
94            sstable,
95            chunk_index,
96            aux,
97        }
98    }
99}
100
101/// One shard: an unbounded-by-count `LruCache` plus running byte accounting.
102struct Shard {
103    lru: LruCache<ChunkKey, Arc<[u8]>>,
104    current_bytes: usize,
105}
106
107impl Shard {
108    fn new() -> Self {
109        Self {
110            // Used UNBOUNDED by count; the byte budget is enforced explicitly in
111            // `insert` via `pop_lru`. `LruCache::unbounded` never evicts on its own.
112            lru: LruCache::unbounded(),
113            current_bytes: 0,
114        }
115    }
116}
117
118/// A shared, bytes-bounded, sharded decompressed-chunk cache.
119pub struct DecompressedChunkCache {
120    shards: Box<[Mutex<Shard>]>,
121    /// `shards.len() - 1`; `shards.len()` is always a power of two.
122    mask: usize,
123    /// Per-shard byte budget (`total_budget / shards.len()`, at least 1).
124    budget_per_shard: usize,
125    hits: AtomicU64,
126    misses: AtomicU64,
127}
128
129impl std::fmt::Debug for DecompressedChunkCache {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.debug_struct("DecompressedChunkCache")
132            .field("shards", &self.shards.len())
133            .field("budget_per_shard", &self.budget_per_shard)
134            .field("resident_bytes", &self.resident_bytes())
135            .field("hits", &self.hits.load(Ordering::Relaxed))
136            .field("misses", &self.misses.load(Ordering::Relaxed))
137            .finish()
138    }
139}
140
141impl DecompressedChunkCache {
142    /// Create a cache with `total_budget_bytes` split across [`DEFAULT_SHARDS`]
143    /// shards.
144    pub fn with_budget_bytes(total_budget_bytes: usize) -> Self {
145        Self::with_budget_and_shards(total_budget_bytes, DEFAULT_SHARDS)
146    }
147
148    /// Create a cache with `total_budget_bytes` split across `shard_count` shards.
149    ///
150    /// `shard_count` is rounded UP to the next power of two (min 1) so shard
151    /// selection can mask instead of modulo. Unit tests use `shard_count = 1` for
152    /// deterministic eviction ordering; production uses [`DEFAULT_SHARDS`].
153    pub fn with_budget_and_shards(total_budget_bytes: usize, shard_count: usize) -> Self {
154        let shard_count = shard_count.max(1).next_power_of_two();
155        let budget_per_shard = (total_budget_bytes / shard_count).max(1);
156        let mut shards = Vec::with_capacity(shard_count);
157        for _ in 0..shard_count {
158            shards.push(Mutex::new(Shard::new()));
159        }
160        Self {
161            shards: shards.into_boxed_slice(),
162            mask: shard_count - 1,
163            budget_per_shard,
164            hits: AtomicU64::new(0),
165            misses: AtomicU64::new(0),
166        }
167    }
168
169    /// Poison-tolerant lock: recover the guard if a prior holder panicked, so a
170    /// single panic cannot turn the cache into a panic-for-everyone (design D2).
171    #[inline]
172    fn lock(m: &Mutex<Shard>) -> std::sync::MutexGuard<'_, Shard> {
173        m.lock().unwrap_or_else(|e| e.into_inner())
174    }
175
176    #[inline]
177    fn shard_for(&self, key: &ChunkKey) -> &Mutex<Shard> {
178        let mut h = std::collections::hash_map::DefaultHasher::new();
179        key.hash(&mut h);
180        let idx = (h.finish() as usize) & self.mask;
181        // `idx <= mask < shards.len()`, so this index is always in bounds.
182        &self.shards[idx]
183    }
184
185    /// Look up a resident chunk. On a hit this bumps recency and returns an
186    /// `Arc::clone` (no chunk-sized allocation). On a miss returns `None`.
187    pub fn get(&self, key: &ChunkKey) -> Option<Arc<[u8]>> {
188        let mut guard = Self::lock(self.shard_for(key));
189        match guard.lru.get(key) {
190            Some(v) => {
191                let v = Arc::clone(v);
192                drop(guard);
193                self.hits.fetch_add(1, Ordering::Relaxed);
194                Some(v)
195            }
196            None => {
197                drop(guard);
198                self.misses.fetch_add(1, Ordering::Relaxed);
199                None
200            }
201        }
202    }
203
204    /// Insert `data` under `key`, returning the resident `Arc<[u8]>`.
205    ///
206    /// The `Vec<u8>` is converted to `Arc<[u8]>` exactly once here; the returned
207    /// handle and any subsequent [`get`](Self::get) share that one buffer. After
208    /// insertion the owning shard evicts LRU entries until it is within its byte
209    /// budget (never evicting the just-inserted entry).
210    pub fn insert(&self, key: ChunkKey, data: Vec<u8>) -> Arc<[u8]> {
211        let arc: Arc<[u8]> = Arc::from(data.into_boxed_slice());
212        let len = arc.len();
213        let mut guard = Self::lock(self.shard_for(&key));
214
215        // Replacing an existing entry: reclaim its byte weight first.
216        if let Some(old) = guard.lru.put(key, Arc::clone(&arc)) {
217            guard.current_bytes = guard.current_bytes.saturating_sub(old.len());
218        }
219        guard.current_bytes = guard.current_bytes.saturating_add(len);
220
221        // Evict LRU entries until within budget. The just-inserted key is now
222        // most-recently-used, so `pop_lru` never targets it while other entries
223        // remain. The `len() > 1` guard keeps the chunk we just produced resident
224        // even if it alone exceeds the budget (documented: single oversized entry).
225        while guard.current_bytes > self.budget_per_shard && guard.lru.len() > 1 {
226            match guard.lru.pop_lru() {
227                Some((_, evicted)) => {
228                    guard.current_bytes = guard.current_bytes.saturating_sub(evicted.len());
229                }
230                None => break,
231            }
232        }
233        arc
234    }
235
236    /// Total resident decompressed bytes across all shards.
237    pub fn resident_bytes(&self) -> usize {
238        self.shards
239            .iter()
240            .map(|m| Self::lock(m).current_bytes)
241            .sum()
242    }
243
244    /// Total resident entry count across all shards.
245    pub fn len(&self) -> usize {
246        self.shards.iter().map(|m| Self::lock(m).lru.len()).sum()
247    }
248
249    /// Whether the cache currently holds no entries.
250    pub fn is_empty(&self) -> bool {
251        self.len() == 0
252    }
253
254    /// The configured total byte budget (`budget_per_shard * shard count`).
255    pub fn budget_bytes(&self) -> usize {
256        self.budget_per_shard * self.shards.len()
257    }
258
259    /// Cumulative cache hits (test/observability instrumentation).
260    pub fn hit_count(&self) -> u64 {
261        self.hits.load(Ordering::Relaxed)
262    }
263
264    /// Cumulative cache misses (test/observability instrumentation).
265    pub fn miss_count(&self) -> u64 {
266        self.misses.load(Ordering::Relaxed)
267    }
268}
269
270impl Default for DecompressedChunkCache {
271    fn default() -> Self {
272        Self::with_budget_bytes(DEFAULT_BUDGET_BYTES)
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn chunk(byte: u8, len: usize) -> Vec<u8> {
281        vec![byte; len]
282    }
283
284    /// Task 1.1: eviction order — budget holds exactly 2 equal chunks; access
285    /// A, B, A, then insert C → B is evicted (LRU), A and C survive.
286    #[test]
287    fn eviction_order_lru() {
288        // Single shard for deterministic ordering. Budget = 2 * 100 bytes.
289        let cache = DecompressedChunkCache::with_budget_and_shards(200, 1);
290        let a = ChunkKey::new(1, 0);
291        let b = ChunkKey::new(1, 1);
292        let c = ChunkKey::new(1, 2);
293
294        cache.insert(a, chunk(0xAA, 100));
295        cache.insert(b, chunk(0xBB, 100));
296        // Access A so it becomes more-recently-used than B.
297        assert!(cache.get(&a).is_some());
298        // Insert C: over budget (300 > 200) → evict LRU, which is now B.
299        cache.insert(c, chunk(0xCC, 100));
300
301        assert!(cache.get(&a).is_some(), "A (recently used) must survive");
302        assert!(cache.get(&c).is_some(), "C (just inserted) must survive");
303        assert!(
304            cache.get(&b).is_none(),
305            "B (least recently used) must be evicted"
306        );
307        assert!(cache.resident_bytes() <= 200);
308    }
309
310    /// Task 1.2: byte budget — inserting more distinct chunks than the budget can
311    /// hold keeps resident bytes within budget after every insert.
312    #[test]
313    fn byte_budget_bounded() {
314        let budget = 500usize;
315        let cache = DecompressedChunkCache::with_budget_and_shards(budget, 1);
316        for i in 0..50u64 {
317            cache.insert(ChunkKey::new(7, i), chunk(i as u8, 100));
318            assert!(
319                cache.resident_bytes() <= budget,
320                "resident {} exceeded budget {} after insert {}",
321                cache.resident_bytes(),
322                budget,
323                i
324            );
325        }
326        // At 100 bytes/entry and a 500-byte budget, at most 5 entries survive.
327        assert!(
328            cache.len() <= 5,
329            "entry count must stay bounded (got {})",
330            cache.len()
331        );
332    }
333
334    /// A single entry larger than the budget is retained (never evict below one
335    /// live entry) — the read path must return the chunk it just produced.
336    #[test]
337    fn single_oversized_entry_retained() {
338        let cache = DecompressedChunkCache::with_budget_and_shards(100, 1);
339        let k = ChunkKey::new(1, 0);
340        cache.insert(k, chunk(0xEE, 4096));
341        assert!(
342            cache.get(&k).is_some(),
343            "oversized entry must remain resident"
344        );
345        assert_eq!(cache.len(), 1);
346    }
347
348    /// Task 1.3: zero-copy hit — insert once, get twice; every handle points at
349    /// the SAME underlying buffer (Arc pointer identity), no chunk-sized copy.
350    #[test]
351    fn zero_copy_hit_pointer_identity() {
352        let cache = DecompressedChunkCache::with_budget_and_shards(1 << 20, 1);
353        let k = ChunkKey::new(3, 9);
354        let inserted = cache.insert(k, chunk(0x42, 1024));
355
356        let h1 = cache.get(&k).expect("first hit");
357        let h2 = cache.get(&k).expect("second hit");
358
359        // Same allocation: pointer identity, not a value clone.
360        assert!(Arc::ptr_eq(&inserted, &h1));
361        assert!(Arc::ptr_eq(&inserted, &h2));
362        assert_eq!(h1.as_ptr(), inserted.as_ptr());
363        assert_eq!(&*h1, &chunk(0x42, 1024)[..]);
364    }
365
366    /// Task 1.4: concurrency soundness — many threads read overlapping + disjoint
367    /// keys under eviction pressure. Every returned buffer is complete/correct,
368    /// resident bytes stay within budget, no panic.
369    #[test]
370    fn concurrency_soundness() {
371        use std::thread;
372
373        // Small budget vs working set so eviction runs constantly.
374        let cache = Arc::new(DecompressedChunkCache::with_budget_bytes(64 * 1024));
375        let chunk_len = 256usize;
376        let n_keys = 512u64;
377
378        let mut handles = Vec::new();
379        for t in 0..8u64 {
380            let cache = Arc::clone(&cache);
381            handles.push(thread::spawn(move || {
382                for round in 0..2000u64 {
383                    let idx = (t.wrapping_mul(31).wrapping_add(round)) % n_keys;
384                    let key = ChunkKey::new(1, idx);
385                    let expect_byte = idx as u8;
386                    let got = match cache.get(&key) {
387                        Some(v) => v,
388                        None => cache.insert(key, vec![expect_byte; chunk_len]),
389                    };
390                    // Whatever we got must be a complete, correct chunk for `idx`
391                    // (never torn/partial, never another key's bytes).
392                    assert_eq!(got.len(), chunk_len);
393                    assert!(got.iter().all(|&b| b == expect_byte));
394                }
395            }));
396        }
397        for h in handles {
398            h.join().expect("worker thread must not panic");
399        }
400        assert!(
401            cache.resident_bytes() <= cache.budget_bytes(),
402            "resident {} exceeded budget {}",
403            cache.resident_bytes(),
404            cache.budget_bytes()
405        );
406    }
407
408    /// Poison recovery: a panic while holding a shard lock must not wedge the
409    /// cache — subsequent ops recover the guard and continue.
410    #[test]
411    fn poisoned_lock_recovers() {
412        use std::panic::{catch_unwind, AssertUnwindSafe};
413        use std::thread;
414
415        let cache = Arc::new(DecompressedChunkCache::with_budget_and_shards(1 << 20, 1));
416        let k = ChunkKey::new(1, 0);
417        cache.insert(k, chunk(0x55, 16));
418
419        // Poison the single shard's mutex by panicking while holding it.
420        let poison_cache = Arc::clone(&cache);
421        let _ = thread::spawn(move || {
422            let _guard = DecompressedChunkCache::lock(&poison_cache.shards[0]);
423            panic!("intentional poison");
424        })
425        .join();
426
427        // The cache must still serve reads/writes without panicking.
428        let res = catch_unwind(AssertUnwindSafe(|| {
429            let hit = cache.get(&k);
430            cache.insert(ChunkKey::new(1, 1), chunk(0x66, 16));
431            hit.is_some()
432        }));
433        assert_eq!(
434            res.ok(),
435            Some(true),
436            "cache must recover from a poisoned lock"
437        );
438    }
439
440    #[test]
441    fn shard_count_rounds_to_power_of_two() {
442        let cache = DecompressedChunkCache::with_budget_and_shards(1 << 20, 10);
443        assert_eq!(cache.shards.len(), 16);
444        assert_eq!(cache.mask, 15);
445    }
446
447    /// roborev #1567: a size-dependent range read (BIG point-read path) keyed by
448    /// offset alone would return the first-cached range when the same offset is
449    /// later read with a different size. The `aux` discriminant (size) makes the
450    /// key complete, so a same-offset/different-size read MISSES rather than
451    /// aliasing to the wrong bytes.
452    #[test]
453    fn ranged_key_does_not_alias_same_offset_different_size() {
454        let cache = DecompressedChunkCache::with_budget_bytes(1 << 20);
455        let sstable = 0xABCD_u64;
456        let offset = 4096_u64;
457
458        // Cache a 16-byte range at `offset`.
459        let k16 = ChunkKey::with_aux(sstable, offset, 16);
460        let v16 = cache.insert(k16, chunk(0x11, 16));
461
462        // A read at the SAME offset but a DIFFERENT size must not hit `v16`.
463        let k32 = ChunkKey::with_aux(sstable, offset, 32);
464        assert!(
465            cache.get(&k32).is_none(),
466            "same offset with a different size must not alias the cached range"
467        );
468
469        // The original size still hits the original bytes (Arc identity).
470        let again = cache.get(&k16).expect("original ranged key still resident");
471        assert!(
472            Arc::ptr_eq(&v16, &again),
473            "same (offset,size) key returns the same buffer"
474        );
475
476        // aux == 0 (whole-chunk sites) is a distinct namespace from any sized read.
477        let k0 = ChunkKey::new(sstable, offset);
478        assert!(
479            cache.get(&k0).is_none(),
480            "whole-chunk key (aux=0) must not alias a sized range read"
481        );
482    }
483}