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 = [`bytes::Bytes`]** (D3; substrate migration issue #1940): a hit is a
13//! `Bytes::clone` (refcount bump), never a chunk-sized memcpy. Insert converts the
14//! decompressed `Vec<u8>` once via `Bytes::from(vec)`, which is ZERO-COPY — it
15//! reuses the `Vec`'s existing heap allocation rather than allocating a fresh
16//! `Arc<[u8]>` backing store and memcpy'ing into it (as `Arc::from(boxed_slice)`
17//! did). This is what lets the windowed scan reach ≤1 alloc/chunk: the single
18//! decompress-output allocation flows all the way into the cache untouched, and a
19//! window fill borrows a refcounted `Bytes` view of it. `Bytes` is `Arc`-backed,
20//! so the refcount-bump-on-hit contract is preserved verbatim.
21//! - **Bytes-bounded, not entry-count** (spec R1): each entry is weighed by its
22//! decompressed length; after an insert the owning shard evicts LRU entries
23//! until it is within its byte budget. A single entry larger than the budget is
24//! retained (we never evict below one live entry) — the read path must always be
25//! able to return the chunk it just produced.
26//! - **Hand-sharded `Mutex<LruCache>`** (D2): `shards.len()` is a power of two.
27//! The hit path locks exactly ONE shard and calls `LruCache::get` (which mutates
28//! recency — hence a `Mutex`, not an `RwLock`; but sharded so contention is
29//! `1/N`, never a single process-wide lock). Reuses the tested `lru` crate
30//! internals; no new external dependency.
31//! - **Poison-tolerant** (D2): every lock is taken with
32//! `lock().unwrap_or_else(|e| e.into_inner())` so one panicking thread cannot
33//! turn the cache into a panic-for-everyone. No `unwrap()`/`expect()` here.
34//! - **No-heuristics** (mandate #28): keys are `(u64 sstable id, u64 chunk index)`
35//! derived from authoritative reader identity + chunk offsets — never inferred
36//! from decompressed byte content.
37//!
38//! The default is a `Box<[Mutex<Shard>]>` rather than a fixed `[Mutex<Shard>; N]`
39//! array so the shard count is a constructor parameter: production uses
40//! [`DEFAULT_SHARDS`], while unit tests use a single shard for deterministic
41//! eviction ordering. Both remain power-of-two hand-sharded `Mutex<LruCache>` —
42//! the design intent is preserved.
43
44pub mod global_key_offset;
45
46pub(crate) use global_key_offset::GlobalKeyCacheSnapshot;
47pub use global_key_offset::{
48 GenerationIdentity, GlobalKeyOffsetCache, PartitionLoc, DEFAULT_GLOBAL_KEY_CACHE_BYTES,
49 DEFAULT_GLOBAL_KEY_CACHE_SHARDS,
50};
51
52use bytes::Bytes;
53use lru::LruCache;
54use std::hash::{Hash, Hasher};
55use std::sync::atomic::{AtomicU64, Ordering};
56use std::sync::Mutex;
57
58/// Default shard count (power of two). Production readers use this via
59/// [`DecompressedChunkCache::with_budget_bytes`].
60pub const DEFAULT_SHARDS: usize = 16;
61
62/// Default total byte budget when a cache is constructed without an explicit
63/// budget (mirrors `config.memory.block_cache.max_size`'s 256 MiB default).
64pub const DEFAULT_BUDGET_BYTES: usize = 256 * 1024 * 1024;
65
66/// Authoritative cache key: an SSTable identity hash and a chunk discriminator.
67///
68/// `chunk_index` carries whichever authoritative offset the wiring site uses —
69/// a real chunk index (windowed scan, BTI target chunk) or an index-resolved
70/// `block_offset` (BIG point read). Sites keep separate key namespaces by folding
71/// a site salt into `sstable` (design D4), so numerically-overlapping values from
72/// different sites never collide.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
74pub struct ChunkKey {
75 /// Stable hash of the SSTable's `file_path` (+ generation), optionally XORed
76 /// with a per-site namespace salt.
77 pub sstable: u64,
78 /// Authoritative chunk index or index-resolved offset within `sstable`.
79 pub chunk_index: u64,
80 /// Extra discriminant for a size-dependent range read. The chunk-index sites
81 /// (BTI, windowed scan) decompress a whole compression chunk whose bytes are
82 /// fully determined by `chunk_index`, so they use `aux = 0`. The BIG
83 /// point-read path (`get_cached_data`) reads an index-resolved
84 /// `(block_offset, size)` byte range whose decompressed content depends on
85 /// BOTH the offset AND the length: two reads at the same `block_offset` with
86 /// different `size` decompress different input and MUST NOT alias. `aux`
87 /// carries `size` for that path so the key is complete (roborev #1567), and
88 /// because `(u64 offset, u32 size)` cannot be packed into one `u64` without
89 /// loss, `aux` is a first-class key field, not folded into `chunk_index`.
90 pub aux: u64,
91}
92
93impl ChunkKey {
94 /// Construct a key from an sstable identity hash and a chunk discriminator
95 /// (whole-chunk sites; `aux = 0`).
96 #[inline]
97 pub fn new(sstable: u64, chunk_index: u64) -> Self {
98 Self {
99 sstable,
100 chunk_index,
101 aux: 0,
102 }
103 }
104
105 /// Construct a key with an extra discriminant (`aux`) for a size-dependent
106 /// range read (the BIG point-read path keys `aux = size`).
107 #[inline]
108 pub fn with_aux(sstable: u64, chunk_index: u64, aux: u64) -> Self {
109 Self {
110 sstable,
111 chunk_index,
112 aux,
113 }
114 }
115}
116
117/// One shard: an unbounded-by-count `LruCache` plus running byte accounting.
118struct Shard {
119 lru: LruCache<ChunkKey, Bytes>,
120 current_bytes: usize,
121}
122
123impl Shard {
124 fn new() -> Self {
125 Self {
126 // Used UNBOUNDED by count; the byte budget is enforced explicitly in
127 // `insert` via `pop_lru`. `LruCache::unbounded` never evicts on its own.
128 lru: LruCache::unbounded(),
129 current_bytes: 0,
130 }
131 }
132}
133
134/// A shared, bytes-bounded, sharded decompressed-chunk cache.
135pub struct DecompressedChunkCache {
136 shards: Box<[Mutex<Shard>]>,
137 /// `shards.len() - 1`; `shards.len()` is always a power of two.
138 mask: usize,
139 /// Per-shard byte budget (`total_budget / shards.len()`, at least 1).
140 budget_per_shard: usize,
141 /// When `true` this is a genuine no-op cache (issue #1568): `get` never
142 /// stores/returns anything and `insert` never retains, so reads bypass the
143 /// cache entirely. Built by [`disabled`](Self::disabled) when
144 /// `block_cache.enabled == false`. Distinct from a zero-budget cache, which
145 /// would still retain one oversized entry per shard.
146 disabled: bool,
147 hits: AtomicU64,
148 misses: AtomicU64,
149 /// Cumulative count of entries evicted to stay within the byte budget
150 /// (issue #1571, B5). Relaxed atomic — advisory observability, never on a
151 /// fence-bearing path.
152 evictions: AtomicU64,
153}
154
155impl std::fmt::Debug for DecompressedChunkCache {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.debug_struct("DecompressedChunkCache")
158 .field("shards", &self.shards.len())
159 .field("budget_per_shard", &self.budget_per_shard)
160 .field("resident_bytes", &self.resident_bytes())
161 .field("hits", &self.hits.load(Ordering::Relaxed))
162 .field("misses", &self.misses.load(Ordering::Relaxed))
163 .field("evictions", &self.evictions.load(Ordering::Relaxed))
164 .finish()
165 }
166}
167
168impl DecompressedChunkCache {
169 /// Create a cache with `total_budget_bytes` split across [`DEFAULT_SHARDS`]
170 /// shards.
171 pub fn with_budget_bytes(total_budget_bytes: usize) -> Self {
172 Self::with_budget_and_shards(total_budget_bytes, DEFAULT_SHARDS)
173 }
174
175 /// Create a cache with `total_budget_bytes` split across `shard_count` shards.
176 ///
177 /// `shard_count` is rounded UP to the next power of two (min 1) so shard
178 /// selection can mask instead of modulo. Unit tests use `shard_count = 1` for
179 /// deterministic eviction ordering; production uses [`DEFAULT_SHARDS`].
180 pub fn with_budget_and_shards(total_budget_bytes: usize, shard_count: usize) -> Self {
181 let shard_count = shard_count.max(1).next_power_of_two();
182 let budget_per_shard = (total_budget_bytes / shard_count).max(1);
183 let mut shards = Vec::with_capacity(shard_count);
184 for _ in 0..shard_count {
185 shards.push(Mutex::new(Shard::new()));
186 }
187 Self {
188 shards: shards.into_boxed_slice(),
189 mask: shard_count - 1,
190 budget_per_shard,
191 disabled: false,
192 hits: AtomicU64::new(0),
193 misses: AtomicU64::new(0),
194 evictions: AtomicU64::new(0),
195 }
196 }
197
198 /// Create a genuine no-op cache (issue #1568): reads bypass it entirely.
199 ///
200 /// Used when `config.memory.block_cache.enabled == false` so the advertised
201 /// toggle really disables caching instead of being decorative. [`get`](Self::get)
202 /// always returns `None` (no counters touched), [`insert`](Self::insert) returns
203 /// the `Arc` without retaining it, and `resident_bytes()` / `len()` /
204 /// `budget_bytes()` all report `0`. A single dummy shard is allocated only so
205 /// shard indexing stays valid; nothing is ever stored in it.
206 pub fn disabled() -> Self {
207 Self {
208 shards: vec![Mutex::new(Shard::new())].into_boxed_slice(),
209 mask: 0,
210 budget_per_shard: 0,
211 disabled: true,
212 hits: AtomicU64::new(0),
213 misses: AtomicU64::new(0),
214 evictions: AtomicU64::new(0),
215 }
216 }
217
218 /// Poison-tolerant lock: recover the guard if a prior holder panicked, so a
219 /// single panic cannot turn the cache into a panic-for-everyone (design D2).
220 #[inline]
221 fn lock(m: &Mutex<Shard>) -> std::sync::MutexGuard<'_, Shard> {
222 m.lock().unwrap_or_else(|e| e.into_inner())
223 }
224
225 #[inline]
226 fn shard_for(&self, key: &ChunkKey) -> &Mutex<Shard> {
227 let mut h = std::collections::hash_map::DefaultHasher::new();
228 key.hash(&mut h);
229 let idx = (h.finish() as usize) & self.mask;
230 // `idx <= mask < shards.len()`, so this index is always in bounds.
231 &self.shards[idx]
232 }
233
234 /// Look up a resident chunk. On a hit this bumps recency and returns a
235 /// `Bytes::clone` (refcount bump, no chunk-sized allocation). On a miss returns
236 /// `None`.
237 pub fn get(&self, key: &ChunkKey) -> Option<Bytes> {
238 // No-op cache (issue #1568): a disabled cache never holds anything, so
239 // reads bypass it without touching the hit/miss counters.
240 if self.disabled {
241 return None;
242 }
243 let mut guard = Self::lock(self.shard_for(key));
244 match guard.lru.get(key) {
245 Some(v) => {
246 let v = v.clone();
247 drop(guard);
248 self.hits.fetch_add(1, Ordering::Relaxed);
249 Some(v)
250 }
251 None => {
252 drop(guard);
253 self.misses.fetch_add(1, Ordering::Relaxed);
254 None
255 }
256 }
257 }
258
259 /// Insert `data` under `key`, returning the resident [`Bytes`].
260 ///
261 /// The `Vec<u8>` is converted to `Bytes` exactly once here via `Bytes::from`,
262 /// which is ZERO-COPY: it takes ownership of the `Vec`'s existing heap
263 /// allocation rather than allocating a new backing store and copying (issue
264 /// #1940 substrate). The returned handle and any subsequent [`get`](Self::get)
265 /// share that one buffer (refcount-bumped clones). After insertion the owning
266 /// shard evicts LRU entries until it is within its byte budget (never evicting
267 /// the just-inserted entry).
268 pub fn insert(&self, key: ChunkKey, data: Vec<u8>) -> Bytes {
269 let bytes = Bytes::from(data);
270 // No-op cache (issue #1568): return the freshly-produced buffer to the
271 // caller without retaining it, so a disabled cache holds nothing.
272 if self.disabled {
273 return bytes;
274 }
275 let len = bytes.len();
276 let mut guard = Self::lock(self.shard_for(&key));
277
278 // Replacing an existing entry: reclaim its byte weight first.
279 if let Some(old) = guard.lru.put(key, bytes.clone()) {
280 guard.current_bytes = guard.current_bytes.saturating_sub(old.len());
281 }
282 guard.current_bytes = guard.current_bytes.saturating_add(len);
283
284 // Evict LRU entries until within budget. The just-inserted key is now
285 // most-recently-used, so `pop_lru` never targets it while other entries
286 // remain. The `len() > 1` guard keeps the chunk we just produced resident
287 // even if it alone exceeds the budget (documented: single oversized entry).
288 let mut evicted_here: u64 = 0;
289 while guard.current_bytes > self.budget_per_shard && guard.lru.len() > 1 {
290 match guard.lru.pop_lru() {
291 Some((_, evicted)) => {
292 guard.current_bytes = guard.current_bytes.saturating_sub(evicted.len());
293 evicted_here += 1;
294 }
295 None => break,
296 }
297 }
298 drop(guard);
299 if evicted_here > 0 {
300 self.evictions.fetch_add(evicted_here, Ordering::Relaxed);
301 }
302 bytes
303 }
304
305 /// Total resident decompressed bytes across all shards.
306 pub fn resident_bytes(&self) -> usize {
307 self.shards
308 .iter()
309 .map(|m| Self::lock(m).current_bytes)
310 .sum()
311 }
312
313 /// Total resident entry count across all shards.
314 pub fn len(&self) -> usize {
315 self.shards.iter().map(|m| Self::lock(m).lru.len()).sum()
316 }
317
318 /// Whether the cache currently holds no entries.
319 pub fn is_empty(&self) -> bool {
320 self.len() == 0
321 }
322
323 /// The configured total byte budget (`budget_per_shard * shard count`).
324 ///
325 /// A [`disabled`](Self::disabled) no-op cache reports `0` (it holds nothing).
326 pub fn budget_bytes(&self) -> usize {
327 self.budget_per_shard * self.shards.len()
328 }
329
330 /// Cumulative cache hits (test/observability instrumentation).
331 pub fn hit_count(&self) -> u64 {
332 self.hits.load(Ordering::Relaxed)
333 }
334
335 /// Cumulative cache misses (test/observability instrumentation).
336 pub fn miss_count(&self) -> u64 {
337 self.misses.load(Ordering::Relaxed)
338 }
339
340 /// Cumulative entries evicted to stay within budget (issue #1571, B5).
341 ///
342 /// Real relaxed-atomic counter; a [`disabled`](Self::disabled) no-op cache
343 /// never evicts and reports `0`.
344 pub fn eviction_count(&self) -> u64 {
345 self.evictions.load(Ordering::Relaxed)
346 }
347}
348
349impl Default for DecompressedChunkCache {
350 fn default() -> Self {
351 Self::with_budget_bytes(DEFAULT_BUDGET_BYTES)
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use std::sync::Arc;
359
360 fn chunk(byte: u8, len: usize) -> Vec<u8> {
361 vec![byte; len]
362 }
363
364 /// Task 1.1: eviction order — budget holds exactly 2 equal chunks; access
365 /// A, B, A, then insert C → B is evicted (LRU), A and C survive.
366 #[test]
367 fn eviction_order_lru() {
368 // Single shard for deterministic ordering. Budget = 2 * 100 bytes.
369 let cache = DecompressedChunkCache::with_budget_and_shards(200, 1);
370 let a = ChunkKey::new(1, 0);
371 let b = ChunkKey::new(1, 1);
372 let c = ChunkKey::new(1, 2);
373
374 cache.insert(a, chunk(0xAA, 100));
375 cache.insert(b, chunk(0xBB, 100));
376 // Access A so it becomes more-recently-used than B.
377 assert!(cache.get(&a).is_some());
378 // Insert C: over budget (300 > 200) → evict LRU, which is now B.
379 cache.insert(c, chunk(0xCC, 100));
380
381 assert!(cache.get(&a).is_some(), "A (recently used) must survive");
382 assert!(cache.get(&c).is_some(), "C (just inserted) must survive");
383 assert!(
384 cache.get(&b).is_none(),
385 "B (least recently used) must be evicted"
386 );
387 assert!(cache.resident_bytes() <= 200);
388 }
389
390 /// Task 1.2: byte budget — inserting more distinct chunks than the budget can
391 /// hold keeps resident bytes within budget after every insert.
392 #[test]
393 fn byte_budget_bounded() {
394 let budget = 500usize;
395 let cache = DecompressedChunkCache::with_budget_and_shards(budget, 1);
396 for i in 0..50u64 {
397 cache.insert(ChunkKey::new(7, i), chunk(i as u8, 100));
398 assert!(
399 cache.resident_bytes() <= budget,
400 "resident {} exceeded budget {} after insert {}",
401 cache.resident_bytes(),
402 budget,
403 i
404 );
405 }
406 // At 100 bytes/entry and a 500-byte budget, at most 5 entries survive.
407 assert!(
408 cache.len() <= 5,
409 "entry count must stay bounded (got {})",
410 cache.len()
411 );
412 }
413
414 /// Issue #1571 (B5): the eviction counter reflects the real number of entries
415 /// evicted to stay within budget, and an insert within budget increments it by
416 /// zero (no-fabrication).
417 #[test]
418 fn eviction_count_tracks_real_evictions() {
419 // Budget holds exactly 2 * 100-byte chunks.
420 let cache = DecompressedChunkCache::with_budget_and_shards(200, 1);
421 assert_eq!(cache.eviction_count(), 0, "fresh cache has evicted nothing");
422
423 cache.insert(ChunkKey::new(1, 0), chunk(0x00, 100));
424 cache.insert(ChunkKey::new(1, 1), chunk(0x01, 100));
425 // Still within budget (200 <= 200): no eviction yet.
426 assert_eq!(cache.eviction_count(), 0);
427
428 // Each further distinct insert pushes over budget → exactly one eviction.
429 cache.insert(ChunkKey::new(1, 2), chunk(0x02, 100));
430 assert_eq!(cache.eviction_count(), 1);
431 cache.insert(ChunkKey::new(1, 3), chunk(0x03, 100));
432 assert_eq!(cache.eviction_count(), 2);
433 }
434
435 /// A disabled no-op cache never evicts and reports a real zero.
436 #[test]
437 fn disabled_cache_reports_zero_evictions() {
438 let cache = DecompressedChunkCache::disabled();
439 for i in 0..10u64 {
440 cache.insert(ChunkKey::new(1, i), chunk(i as u8, 4096));
441 }
442 assert_eq!(cache.eviction_count(), 0);
443 }
444
445 /// A single entry larger than the budget is retained (never evict below one
446 /// live entry) — the read path must return the chunk it just produced.
447 #[test]
448 fn single_oversized_entry_retained() {
449 let cache = DecompressedChunkCache::with_budget_and_shards(100, 1);
450 let k = ChunkKey::new(1, 0);
451 cache.insert(k, chunk(0xEE, 4096));
452 assert!(
453 cache.get(&k).is_some(),
454 "oversized entry must remain resident"
455 );
456 assert_eq!(cache.len(), 1);
457 }
458
459 /// Task 1.3: zero-copy hit — insert once, get twice; every handle points at
460 /// the SAME underlying buffer (`Bytes` shares the backing allocation), no
461 /// chunk-sized copy. `Bytes` has no `ptr_eq`, so buffer identity is proven via
462 /// `as_ptr()` (a clone shares the backing store and reports the same data
463 /// pointer; a copy would report a different one).
464 #[test]
465 fn zero_copy_hit_pointer_identity() {
466 let cache = DecompressedChunkCache::with_budget_and_shards(1 << 20, 1);
467 let k = ChunkKey::new(3, 9);
468 let inserted = cache.insert(k, chunk(0x42, 1024));
469
470 let h1 = cache.get(&k).expect("first hit");
471 let h2 = cache.get(&k).expect("second hit");
472
473 // Same allocation: shared backing store, not a value copy.
474 assert_eq!(h1.as_ptr(), inserted.as_ptr());
475 assert_eq!(h2.as_ptr(), inserted.as_ptr());
476 assert_eq!(&*h1, &chunk(0x42, 1024)[..]);
477 }
478
479 /// Task 1.4: concurrency soundness — many threads read overlapping + disjoint
480 /// keys under eviction pressure. Every returned buffer is complete/correct,
481 /// resident bytes stay within budget, no panic.
482 #[test]
483 fn concurrency_soundness() {
484 use std::thread;
485
486 // Small budget vs working set so eviction runs constantly.
487 let cache = Arc::new(DecompressedChunkCache::with_budget_bytes(64 * 1024));
488 let chunk_len = 256usize;
489 let n_keys = 512u64;
490
491 let mut handles = Vec::new();
492 for t in 0..8u64 {
493 let cache = Arc::clone(&cache);
494 handles.push(thread::spawn(move || {
495 for round in 0..2000u64 {
496 let idx = (t.wrapping_mul(31).wrapping_add(round)) % n_keys;
497 let key = ChunkKey::new(1, idx);
498 let expect_byte = idx as u8;
499 let got = match cache.get(&key) {
500 Some(v) => v,
501 None => cache.insert(key, vec![expect_byte; chunk_len]),
502 };
503 // Whatever we got must be a complete, correct chunk for `idx`
504 // (never torn/partial, never another key's bytes).
505 assert_eq!(got.len(), chunk_len);
506 assert!(got.iter().all(|&b| b == expect_byte));
507 }
508 }));
509 }
510 for h in handles {
511 h.join().expect("worker thread must not panic");
512 }
513 assert!(
514 cache.resident_bytes() <= cache.budget_bytes(),
515 "resident {} exceeded budget {}",
516 cache.resident_bytes(),
517 cache.budget_bytes()
518 );
519 }
520
521 /// Poison recovery: a panic while holding a shard lock must not wedge the
522 /// cache — subsequent ops recover the guard and continue.
523 #[test]
524 fn poisoned_lock_recovers() {
525 use std::panic::{catch_unwind, AssertUnwindSafe};
526 use std::thread;
527
528 let cache = Arc::new(DecompressedChunkCache::with_budget_and_shards(1 << 20, 1));
529 let k = ChunkKey::new(1, 0);
530 cache.insert(k, chunk(0x55, 16));
531
532 // Poison the single shard's mutex by panicking while holding it.
533 let poison_cache = Arc::clone(&cache);
534 let _ = thread::spawn(move || {
535 let _guard = DecompressedChunkCache::lock(&poison_cache.shards[0]);
536 panic!("intentional poison");
537 })
538 .join();
539
540 // The cache must still serve reads/writes without panicking.
541 let res = catch_unwind(AssertUnwindSafe(|| {
542 let hit = cache.get(&k);
543 cache.insert(ChunkKey::new(1, 1), chunk(0x66, 16));
544 hit.is_some()
545 }));
546 assert_eq!(
547 res.ok(),
548 Some(true),
549 "cache must recover from a poisoned lock"
550 );
551 }
552
553 #[test]
554 fn shard_count_rounds_to_power_of_two() {
555 let cache = DecompressedChunkCache::with_budget_and_shards(1 << 20, 10);
556 assert_eq!(cache.shards.len(), 16);
557 assert_eq!(cache.mask, 15);
558 }
559
560 /// roborev #1567: a size-dependent range read (BIG point-read path) keyed by
561 /// offset alone would return the first-cached range when the same offset is
562 /// later read with a different size. The `aux` discriminant (size) makes the
563 /// key complete, so a same-offset/different-size read MISSES rather than
564 /// aliasing to the wrong bytes.
565 #[test]
566 fn ranged_key_does_not_alias_same_offset_different_size() {
567 let cache = DecompressedChunkCache::with_budget_bytes(1 << 20);
568 let sstable = 0xABCD_u64;
569 let offset = 4096_u64;
570
571 // Cache a 16-byte range at `offset`.
572 let k16 = ChunkKey::with_aux(sstable, offset, 16);
573 let v16 = cache.insert(k16, chunk(0x11, 16));
574
575 // A read at the SAME offset but a DIFFERENT size must not hit `v16`.
576 let k32 = ChunkKey::with_aux(sstable, offset, 32);
577 assert!(
578 cache.get(&k32).is_none(),
579 "same offset with a different size must not alias the cached range"
580 );
581
582 // The original size still hits the original bytes (shared backing store).
583 let again = cache.get(&k16).expect("original ranged key still resident");
584 assert_eq!(
585 v16.as_ptr(),
586 again.as_ptr(),
587 "same (offset,size) key returns the same buffer"
588 );
589
590 // aux == 0 (whole-chunk sites) is a distinct namespace from any sized read.
591 let k0 = ChunkKey::new(sstable, offset);
592 assert!(
593 cache.get(&k0).is_none(),
594 "whole-chunk key (aux=0) must not alias a sized range read"
595 );
596 }
597
598 /// Issue #1568: `disabled()` is a genuine no-op cache. `insert` returns the
599 /// produced buffer without retaining it, `get` always misses, and every
600 /// occupancy/budget/counter accessor reports zero — so a read path wired to
601 /// it truly bypasses caching (used when `block_cache.enabled == false`).
602 #[test]
603 fn disabled_cache_is_a_genuine_no_op() {
604 let cache = DecompressedChunkCache::disabled();
605 assert_eq!(cache.budget_bytes(), 0, "disabled cache has no budget");
606
607 let key = ChunkKey::new(1, 0);
608 let arc = cache.insert(key, chunk(0xEE, 4096));
609 assert_eq!(
610 arc.len(),
611 4096,
612 "insert still hands back the produced buffer"
613 );
614
615 assert!(cache.get(&key).is_none(), "disabled cache never retains");
616 assert_eq!(cache.resident_bytes(), 0);
617 assert_eq!(cache.len(), 0);
618 assert!(cache.is_empty());
619 // Bypass touches no counters: a disabled cache reports a structural zero.
620 assert_eq!(cache.hit_count(), 0);
621 assert_eq!(cache.miss_count(), 0);
622 }
623}