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 = [`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}