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