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}