Skip to main content

mongreldb_core/
cache.rs

1//! MVCC-tagged, content-addressed page cache.
2//!
3//! Correctness by construction: every entry carries the [`Epoch`] at which its
4//! page was committed, and the key is the page's content/identity hash. A query
5//! at [`Snapshot`] only reads entries with
6//! `committed_epoch <= snapshot.epoch`; a rewritten page gets a new hash and the
7//! old entry ages out by capacity — so **no invalidation sweep ever runs**.
8//!
9//! Eviction is a frequency-aware CLOCK (a.k.a. second-chance): each entry has a
10//! small access counter; the clock hand sweeps and either evicts a cold
11//! (counter == 0) entry or decrements a hot one (giving it a second chance).
12//! This blends recency (the hand sweep) with frequency (the counter) — an
13//! LRU/LFU hybrid — in O(1) amortized with no linked-list moves.
14//!
15//! An optional persistent cache under `_cache/` (raw on-disk bytes — ciphertext
16//! when the table is encrypted, so no plaintext ever persists) survives restart.
17//!
18//! Stage 1E (S1E-003): both caches can report their live bytes to a
19//! [`crate::memory::MemoryGovernor`] ([`PageCache::with_governor`],
20//! [`DecodedPageCache::with_governor`]) and expose an
21//! `evict_reclaimable(budget)` entry point the governor drives under pressure
22//! escalation step 2. All governor behavior is additive: without an attached
23//! governor the caches are exactly as before.
24
25use crate::epoch::{Epoch, Snapshot};
26use crate::page::CachedPage;
27use std::collections::HashMap;
28use std::path::PathBuf;
29use std::sync::atomic::{AtomicU64, Ordering};
30
31/// Cumulative page-cache access counters (Priority 14: hit visibility). A *hit*
32/// is a lookup that returned a page visible to the snapshot; a *miss* is a
33/// lookup that found nothing or an entry too new for the snapshot (the caller
34/// then reads from disk).
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct CacheStats {
37    pub hits: u64,
38    pub misses: u64,
39    /// Lookups skipped because the cache shard's lock was contended
40    /// (`try_lock` returned `None`). Non-zero values signal contention that
41    /// sharding or a larger shard count could relieve. (§5.8)
42    pub try_lock_misses: u64,
43}
44
45impl CacheStats {
46    /// Fraction of lookups served from cache in `[0, 1]` (`0` when never used).
47    pub fn hit_rate(&self) -> f64 {
48        let total = self.hits + self.misses;
49        if total == 0 {
50            0.0
51        } else {
52            self.hits as f64 / total as f64
53        }
54    }
55}
56
57/// Bounded, MVCC-safe page cache with frequency-aware CLOCK eviction and an
58/// optional persistent backing directory.
59///
60/// When a [`crate::memory::MemoryGovernor`] is attached
61/// ([`with_governor`](Self::with_governor)) the cache reports its live bytes
62/// to the governor (S1E-003): every insert/eviction resizes one per-cache
63/// [`crate::memory::Reservation`], the governor's denial of growth sheds the
64/// coldest entries, and [`evict_reclaimable`](Self::evict_reclaimable) is the
65/// entry point the governor drives under escalation step 2. Without a
66/// governor the cache behaves exactly as before.
67pub struct PageCache {
68    map: HashMap<[u8; 32], Entry>,
69    /// Clock hand indexes into `ring`; `ring` holds every live key once.
70    ring: Vec<[u8; 32]>,
71    hand: usize,
72    capacity_bytes: u64,
73    used_bytes: u64,
74    /// Backing directory for the persistent cache (`_cache/`). Entries are
75    /// spilled/loaded as `<hex(key)>` files (raw on-disk bytes).
76    dir: Option<PathBuf>,
77    persistent: bool,
78    /// Governor reservation tracking `used_bytes` when a governor is
79    /// attached; `None` keeps the cache self-bounded only.
80    grant: Option<crate::memory::Reservation>,
81    /// Lookups served from cache (visible to the snapshot).
82    hits: AtomicU64,
83    /// Lookups that found nothing visible (caller falls through to disk).
84    misses: AtomicU64,
85}
86
87struct Entry {
88    bytes: bytes::Bytes,
89    epoch: Epoch,
90    freq: u8,
91}
92
93const FREQ_MAX: u8 = 3;
94
95impl PageCache {
96    pub fn new(capacity_bytes: u64) -> Self {
97        Self {
98            map: HashMap::new(),
99            ring: Vec::new(),
100            hand: 0,
101            capacity_bytes,
102            used_bytes: 0,
103            dir: None,
104            persistent: false,
105            grant: None,
106            hits: AtomicU64::new(0),
107            misses: AtomicU64::new(0),
108        }
109    }
110
111    /// Attach a [`crate::memory::MemoryGovernor`] (S1E-003): the cache keeps
112    /// one reservation sized to its live bytes, so the governor's per-class
113    /// accounting always reflects this cache, and growth the governor denies
114    /// sheds the coldest entries. Any already-cached bytes (e.g. loaded by
115    /// [`with_persistence`](Self::with_persistence)) are accounted — and
116    /// evicted down if the governor cannot grant them — immediately.
117    pub fn with_governor(
118        mut self,
119        governor: crate::memory::MemoryGovernor,
120        class: crate::memory::MemoryClass,
121    ) -> Self {
122        self.grant = Some(
123            governor
124                .try_reserve(0, class)
125                .expect("zero-byte reservation always succeeds"),
126        );
127        self.sync_governor();
128        self
129    }
130
131    /// Cumulative hit/miss counts since construction (or the last
132    /// [`reset_stats`](Self::reset_stats)). Cheap (`Relaxed` atomic loads).
133    pub fn stats(&self) -> CacheStats {
134        CacheStats {
135            hits: self.hits.load(Ordering::Relaxed),
136            misses: self.misses.load(Ordering::Relaxed),
137            try_lock_misses: 0,
138        }
139    }
140
141    /// Zero the hit/miss counters (e.g. to measure a single query's locality).
142    pub fn reset_stats(&self) {
143        self.hits.store(0, Ordering::Relaxed);
144        self.misses.store(0, Ordering::Relaxed);
145    }
146
147    /// Enable persistence: load any cached pages from `<dir>` and spill future
148    /// evictions/inserts there. Files are raw page bytes (ciphertext when the
149    /// table is encrypted).
150    pub fn with_persistence(mut self, dir: PathBuf) -> Self {
151        self.persistent = true;
152        self.dir = Some(dir.clone());
153        self.load_from_disk();
154        // When a governor is already attached, account for the loaded bytes
155        // (shedding the coldest if the governor cannot grant them).
156        self.sync_governor();
157        self
158    }
159
160    /// Insert a page (replaces any entry with the same key). Spills to the
161    /// persistent backing directory when enabled.
162    pub fn insert(&mut self, page: CachedPage) {
163        let key = page.content_hash;
164        let size = page.bytes.len() as u64;
165        let epoch = page.committed_epoch;
166        let was_new = !self.map.contains_key(&key);
167        if let Some(old) = self.map.insert(
168            key,
169            Entry {
170                bytes: page.bytes.clone(),
171                epoch,
172                freq: 1,
173            },
174        ) {
175            self.used_bytes = self.used_bytes.saturating_sub(old.bytes.len() as u64);
176        } else if was_new {
177            self.ring.push(key);
178        }
179        self.used_bytes += size;
180        self.evict_if_needed();
181        self.sync_governor();
182    }
183
184    /// Fetch the page visible to `snapshot` (the entry's committed epoch must be
185    /// `<= snapshot.epoch`), promoting its frequency. O(1).
186    pub fn get(&mut self, content_hash: &[u8; 32], snapshot: Snapshot) -> Option<bytes::Bytes> {
187        let hit = self
188            .map
189            .get_mut(content_hash)
190            .filter(|e| e.epoch <= snapshot.epoch)
191            .map(|entry| {
192                if entry.freq < FREQ_MAX {
193                    entry.freq += 1;
194                }
195                entry.bytes.clone()
196            });
197        let counter = if hit.is_some() {
198            &self.hits
199        } else {
200            &self.misses
201        };
202        counter.fetch_add(1, Ordering::Relaxed);
203        hit
204    }
205
206    /// Non-blocking probe used by the parallel (rayon) read path: returns a hit
207    /// without contending on a write lock when one is held. `Some` on hit;
208    /// `None` on miss (or if the value is not visible to `snapshot`). The
209    /// caller treats any `None` as "fall through to disk".
210    pub fn try_get(&self, content_hash: &[u8; 32], snapshot: Snapshot) -> Option<bytes::Bytes> {
211        let hit = self.map.get(content_hash).and_then(|e| {
212            if e.epoch <= snapshot.epoch {
213                Some(e.bytes.clone())
214            } else {
215                None
216            }
217        });
218        let counter = if hit.is_some() {
219            &self.hits
220        } else {
221            &self.misses
222        };
223        counter.fetch_add(1, Ordering::Relaxed);
224        hit
225    }
226
227    pub fn used_bytes(&self) -> u64 {
228        self.used_bytes
229    }
230
231    pub fn len(&self) -> usize {
232        self.map.len()
233    }
234
235    pub fn is_empty(&self) -> bool {
236        self.map.is_empty()
237    }
238
239    /// Flush all live entries to the persistent backing dir (best-effort).
240    pub fn flush_to_disk(&self) {
241        if !self.persistent {
242            return;
243        }
244        for (key, entry) in &self.map {
245            self.spill(key, entry.epoch, &entry.bytes);
246        }
247    }
248
249    fn spill(&self, key: &[u8; 32], epoch: Epoch, bytes: &[u8]) {
250        if !self.persistent {
251            return;
252        }
253        let Some(dir) = &self.dir else { return };
254        let _ = std::fs::create_dir_all(dir);
255        let hex = hex_key(key);
256        // Embed the committed epoch in the filename (`<hex>.<epoch>`) so it can
257        // be restored on reload instead of defaulting to a maximal epoch —
258        // otherwise reloaded pages are invisible to ordinary snapshots and the
259        // persistent tier is effectively dead after reopen. The page bytes stay
260        // raw (ciphertext when the table is encrypted).
261        let path = dir.join(format!("{hex}.{}", epoch.0));
262        let tmp = dir.join(format!("{hex}.{}.tmp", epoch.0));
263        if std::fs::write(&tmp, bytes).is_ok() {
264            let _ = std::fs::rename(&tmp, &path);
265        }
266    }
267
268    fn load_from_disk(&mut self) {
269        let Some(dir) = &self.dir else { return };
270        let entries = match std::fs::read_dir(dir) {
271            Ok(e) => e,
272            Err(_) => return,
273        };
274        for entry in entries.flatten() {
275            let name = entry.file_name();
276            let Some(s) = name.to_str() else { continue };
277            // Skip in-progress temp files.
278            if s.ends_with(".tmp") {
279                continue;
280            }
281            // New format: "<64hex>.<epoch>". Legacy raw files ("<64hex>" with
282            // no dot) stay visible to every snapshot.
283            let (key, epoch) = match s.rsplit_once('.') {
284                Some((hex, suffix)) => {
285                    let key = match decode_hex_key(hex) {
286                        Some(k) => k,
287                        None => continue,
288                    };
289                    let epoch = match suffix.parse::<u64>() {
290                        Ok(e) => Epoch(e),
291                        Err(_) => continue,
292                    };
293                    (key, epoch)
294                }
295                None => {
296                    let key = match decode_hex_key(s) {
297                        Some(k) => k,
298                        None => continue,
299                    };
300                    (key, Epoch(u64::MAX))
301                }
302            };
303            if self.map.contains_key(&key) {
304                continue;
305            }
306            let Ok(bytes) = std::fs::read(entry.path()) else {
307                continue;
308            };
309            let size = bytes.len() as u64;
310            if self.used_bytes.saturating_add(size) > self.capacity_bytes {
311                break; // don't exceed capacity on load
312            }
313            self.map.insert(
314                key,
315                Entry {
316                    bytes: bytes::Bytes::from(bytes),
317                    epoch,
318                    freq: 0,
319                },
320            );
321            self.ring.push(key);
322            self.used_bytes += size;
323        }
324    }
325
326    /// Frequency-aware CLOCK eviction: advance the hand, evicting the first cold
327    /// (freq == 0) entry; decrement hot entries as they're passed (second chance).
328    fn evict_if_needed(&mut self) {
329        while self.used_bytes > self.capacity_bytes && self.clock_step() {}
330    }
331
332    /// One CLOCK step: removes exactly one ring slot — evicting its cold entry,
333    /// reclaiming a stale slot, or (when everything is hot) force-evicting the
334    /// inspected slot — and returns `false` only when the ring is empty.
335    fn clock_step(&mut self) -> bool {
336        if self.ring.is_empty() {
337            return false;
338        }
339        // Find a cold slot. Bounded by ~2× live entries per eviction storm.
340        let mut scanned = 0;
341        let len = self.ring.len();
342        loop {
343            let key = self.ring[self.hand];
344            let Some(entry) = self.map.get_mut(&key) else {
345                // Stale ring slot (key removed); reclaim it.
346                self.ring.swap_remove(self.hand);
347                if self.hand >= self.ring.len() && !self.ring.is_empty() {
348                    self.hand %= self.ring.len();
349                }
350                break;
351            };
352            if entry.freq == 0 {
353                // Evict this cold entry.
354                let removed = self.map.remove(&key).expect("entry present");
355                self.spill(&key, removed.epoch, &removed.bytes);
356                self.used_bytes = self.used_bytes.saturating_sub(removed.bytes.len() as u64);
357                self.ring.swap_remove(self.hand);
358                if !self.ring.is_empty() {
359                    self.hand %= self.ring.len();
360                }
361                break;
362            } else {
363                entry.freq -= 1;
364                scanned += 1;
365                if scanned > len * 2 {
366                    // Everything is hot; evict the current slot outright. The
367                    // hand has NOT been advanced yet, so this removes the slot
368                    // we just inspected (not an innocent neighbor).
369                    let removed = self.map.remove(&key).expect("entry present");
370                    self.spill(&key, removed.epoch, &removed.bytes);
371                    self.used_bytes = self.used_bytes.saturating_sub(removed.bytes.len() as u64);
372                    self.ring.swap_remove(self.hand);
373                    if !self.ring.is_empty() {
374                        self.hand %= self.ring.len();
375                    }
376                    break;
377                }
378                self.hand = (self.hand + 1) % len;
379            }
380        }
381        true
382    }
383
384    /// Keep the governor reservation (when attached) sized to `used_bytes`.
385    /// Growth the governor denies is answered by shedding the coldest entries
386    /// until the accounting fits the grant — the cache stays within the
387    /// governor's budget even when other subsystems hold the node under
388    /// pressure. Terminates: each step removes one ring slot, and an empty
389    /// cache (0 bytes) always fits.
390    fn sync_governor(&mut self) {
391        if self.grant.is_none() {
392            return;
393        }
394        loop {
395            let denied = {
396                let used = self.used_bytes;
397                let grant = self.grant.as_mut().expect("checked above");
398                grant.resize(used).is_err()
399            };
400            if !denied || !self.clock_step() {
401                break;
402            }
403        }
404    }
405
406    /// Governor entry point (S1E-003 step 2): evict at least `budget` bytes of
407    /// reclaimable entries (coldest first), returning the bytes actually
408    /// freed. Without an attached governor the cache is self-bounded and this
409    /// is a plain manual trim.
410    pub fn evict_reclaimable(&mut self, budget: u64) -> u64 {
411        let before = self.used_bytes;
412        while before - self.used_bytes < budget && self.clock_step() {}
413        self.sync_governor();
414        before - self.used_bytes
415    }
416}
417
418fn hex_key(key: &[u8; 32]) -> String {
419    let mut s = String::with_capacity(64);
420    for b in key {
421        s.push_str(&format!("{b:02x}"));
422    }
423    s
424}
425
426fn decode_hex_key(hex: &str) -> Option<[u8; 32]> {
427    if hex.len() != 64 {
428        return None;
429    }
430    let mut out = [0u8; 32];
431    for i in 0..32 {
432        out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
433    }
434    Some(out)
435}
436
437/// Bounded LRU/CLOCK cache of **decoded** columnar pages (Phase 15.4). The
438/// [`PageCache`] above holds raw (ciphertext) page bytes; this second layer
439/// caches the post-decompress, post-decrypt typed page so a repeat scan skips
440/// decode entirely. Pages are immutable per `(run_id, column_id, page_seq)`
441/// identity (keyed via [`crate::sorted_run::page_cache_key`]), so there is no
442/// MVCC/invalidation concern — runs never change, and a rewritten page lives in
443/// a different run (different id) and simply misses here.
444///
445/// Governor attachment ([`with_governor`](Self::with_governor)) behaves exactly
446/// as on [`PageCache`]: live bytes are reported to the governor and
447/// [`evict_reclaimable`](Self::evict_reclaimable) is the governor-driven entry
448/// point (S1E-003 step 2).
449pub struct DecodedPageCache {
450    map: HashMap<[u8; 32], std::sync::Arc<crate::columnar::NativeColumn>>,
451    ring: Vec<[u8; 32]>,
452    hand: usize,
453    capacity_bytes: u64,
454    used_bytes: u64,
455    /// Governor reservation tracking `used_bytes` when a governor is
456    /// attached; `None` keeps the cache self-bounded only.
457    grant: Option<crate::memory::Reservation>,
458    /// Lookups served from the decoded cache (skipped decode).
459    hits: AtomicU64,
460    /// Lookups that missed (caller decoded the page).
461    misses: AtomicU64,
462}
463
464impl DecodedPageCache {
465    pub fn new(capacity_bytes: u64) -> Self {
466        Self {
467            map: HashMap::new(),
468            ring: Vec::new(),
469            hand: 0,
470            capacity_bytes,
471            used_bytes: 0,
472            grant: None,
473            hits: AtomicU64::new(0),
474            misses: AtomicU64::new(0),
475        }
476    }
477
478    /// Attach a [`crate::memory::MemoryGovernor`] (S1E-003): the cache keeps
479    /// one reservation sized to its live bytes and sheds entries when the
480    /// governor denies growth. See [`PageCache::with_governor`].
481    pub fn with_governor(
482        mut self,
483        governor: crate::memory::MemoryGovernor,
484        class: crate::memory::MemoryClass,
485    ) -> Self {
486        self.grant = Some(
487            governor
488                .try_reserve(0, class)
489                .expect("zero-byte reservation always succeeds"),
490        );
491        self.sync_governor();
492        self
493    }
494
495    /// Cumulative hit/miss counts since construction (or the last
496    /// [`reset_stats`](Self::reset_stats)).
497    pub fn stats(&self) -> CacheStats {
498        CacheStats {
499            hits: self.hits.load(Ordering::Relaxed),
500            misses: self.misses.load(Ordering::Relaxed),
501            try_lock_misses: 0,
502        }
503    }
504
505    /// Zero the hit/miss counters.
506    pub fn reset_stats(&self) {
507        self.hits.store(0, Ordering::Relaxed);
508        self.misses.store(0, Ordering::Relaxed);
509    }
510
511    /// Non-blocking probe used by the parallel scan path (`&self`, no write
512    /// lock) — returns the cached decoded page on hit, `None` on miss.
513    pub fn try_get(&self, key: &[u8; 32]) -> Option<std::sync::Arc<crate::columnar::NativeColumn>> {
514        match self.map.get(key).cloned() {
515            Some(v) => {
516                self.hits.fetch_add(1, Ordering::Relaxed);
517                Some(v)
518            }
519            None => {
520                self.misses.fetch_add(1, Ordering::Relaxed);
521                None
522            }
523        }
524    }
525
526    /// Insert a decoded page (replaces any entry with the same key). Evicts
527    /// cold entries (CLOCK) when over capacity.
528    pub fn insert(&mut self, key: [u8; 32], col: std::sync::Arc<crate::columnar::NativeColumn>) {
529        let size = col.approx_bytes();
530        let was_new = !self.map.contains_key(&key);
531        if let Some(old) = self.map.insert(key, col) {
532            self.used_bytes = self.used_bytes.saturating_sub(old.approx_bytes());
533        } else if was_new {
534            self.ring.push(key);
535        }
536        self.used_bytes += size;
537        self.evict_if_needed();
538        self.sync_governor();
539    }
540
541    fn evict_if_needed(&mut self) {
542        // Simple CLOCK eviction: while over capacity, evict the slot under the
543        // hand and advance. Decoded pages are cheap to rebuild (decode is fast,
544        // especially under LZ4), so a crude size-bounded policy is sufficient —
545        // the goal is just to cap memory for very hot repeat-scan workloads.
546        while self.used_bytes > self.capacity_bytes && self.clock_step() {}
547    }
548
549    /// One CLOCK step: removes the slot under the hand and returns `false`
550    /// only when the ring is empty.
551    fn clock_step(&mut self) -> bool {
552        if self.ring.is_empty() {
553            return false;
554        }
555        let idx = self.hand.min(self.ring.len() - 1);
556        let key = self.ring.swap_remove(idx);
557        if let Some(col) = self.map.remove(&key) {
558            self.used_bytes = self.used_bytes.saturating_sub(col.approx_bytes());
559        }
560        if self.ring.is_empty() {
561            self.hand = 0;
562        } else {
563            self.hand %= self.ring.len();
564        }
565        true
566    }
567
568    /// Keep the governor reservation (when attached) sized to `used_bytes`;
569    /// see [`PageCache::sync_governor`].
570    fn sync_governor(&mut self) {
571        if self.grant.is_none() {
572            return;
573        }
574        loop {
575            let denied = {
576                let used = self.used_bytes;
577                let grant = self.grant.as_mut().expect("checked above");
578                grant.resize(used).is_err()
579            };
580            if !denied || !self.clock_step() {
581                break;
582            }
583        }
584    }
585
586    /// Governor entry point (S1E-003 step 2): evict at least `budget` bytes of
587    /// reclaimable entries, returning the bytes actually freed.
588    pub fn evict_reclaimable(&mut self, budget: u64) -> u64 {
589        let before = self.used_bytes;
590        while before - self.used_bytes < budget && self.clock_step() {}
591        self.sync_governor();
592        before - self.used_bytes
593    }
594
595    pub fn len(&self) -> usize {
596        self.map.len()
597    }
598
599    pub fn is_empty(&self) -> bool {
600        self.map.is_empty()
601    }
602
603    pub fn used_bytes(&self) -> u64 {
604        self.used_bytes
605    }
606}
607
608/// A sharded mutex wrapper for the page caches. Each key routes to one of `N`
609/// independent shards (each with its own lock), so concurrent rayon workers
610/// probing different keys rarely contend on the same lock. A `try_lock` that
611/// fails under contention is counted as a try-lock-miss. (§5.8)
612pub struct Sharded<T> {
613    shards: Vec<parking_lot::Mutex<T>>,
614    try_lock_misses: AtomicU64,
615}
616
617/// Default shard count — balances contention reduction against per-shard
618/// overhead. Each shard gets `total_capacity / SHARDS` of the budget.
619pub const CACHE_SHARDS: usize = 16;
620
621impl<T> Sharded<T> {
622    pub fn new(n_shards: usize, make: impl FnMut() -> T) -> Self {
623        let mut make = make;
624        let shards = (0..n_shards)
625            .map(|_| parking_lot::Mutex::new(make()))
626            .collect();
627        Self {
628            shards,
629            try_lock_misses: AtomicU64::new(0),
630        }
631    }
632
633    fn idx(&self, key: &[u8; 32]) -> usize {
634        let h = u32::from_le_bytes([key[0], key[1], key[2], key[3]]);
635        (h as usize) % self.shards.len()
636    }
637
638    pub fn try_lock(&self, key: &[u8; 32]) -> Option<parking_lot::MutexGuard<'_, T>> {
639        match self.shards[self.idx(key)].try_lock() {
640            Some(g) => Some(g),
641            None => {
642                self.try_lock_misses.fetch_add(1, Ordering::Relaxed);
643                None
644            }
645        }
646    }
647
648    pub fn lock(&self, key: &[u8; 32]) -> parking_lot::MutexGuard<'_, T> {
649        self.shards[self.idx(key)].lock()
650    }
651
652    pub fn try_lock_misses(&self) -> u64 {
653        self.try_lock_misses.load(Ordering::Relaxed)
654    }
655}
656
657impl Sharded<PageCache> {
658    pub fn stats(&self) -> CacheStats {
659        let mut total = CacheStats::default();
660        for s in &self.shards {
661            let st = s.lock().stats();
662            total.hits += st.hits;
663            total.misses += st.misses;
664        }
665        total.try_lock_misses = self.try_lock_misses();
666        total
667    }
668
669    pub fn reset_stats(&self) {
670        for s in &self.shards {
671            s.lock().reset_stats();
672        }
673        self.try_lock_misses.store(0, Ordering::Relaxed);
674    }
675
676    pub fn flush_to_disk(&self) {
677        for s in &self.shards {
678            s.lock().flush_to_disk();
679        }
680    }
681
682    pub fn len(&self) -> usize {
683        self.shards.iter().map(|s| s.lock().len()).sum()
684    }
685
686    #[allow(clippy::len_without_is_empty)]
687    pub fn is_empty(&self) -> bool {
688        self.len() == 0
689    }
690
691    /// Live bytes across all shards.
692    pub fn used_bytes(&self) -> u64 {
693        self.shards.iter().map(|s| s.lock().used_bytes()).sum()
694    }
695
696    /// Governor entry point (S1E-003 step 2): evict at least `budget` bytes
697    /// across the shards (spread evenly over the remaining shards), returning
698    /// the bytes actually freed.
699    pub fn evict_reclaimable(&self, budget: u64) -> u64 {
700        let n = self.shards.len() as u64;
701        let mut freed = 0u64;
702        for (i, shard) in self.shards.iter().enumerate() {
703            if freed >= budget {
704                break;
705            }
706            // Ceiling division: small budgets still reach the first shards.
707            let left = n - i as u64;
708            let target = (budget - freed).div_ceil(left);
709            freed += shard.lock().evict_reclaimable(target);
710        }
711        freed
712    }
713}
714
715impl crate::memory::Reclaimable for Sharded<PageCache> {
716    fn evict_reclaimable(&self, budget: u64) -> u64 {
717        <Sharded<PageCache>>::evict_reclaimable(self, budget)
718    }
719
720    fn reclaimable_bytes(&self) -> u64 {
721        self.used_bytes()
722    }
723}
724
725impl Sharded<DecodedPageCache> {
726    pub fn stats(&self) -> CacheStats {
727        let mut total = CacheStats::default();
728        for s in &self.shards {
729            let st = s.lock().stats();
730            total.hits += st.hits;
731            total.misses += st.misses;
732        }
733        total.try_lock_misses = self.try_lock_misses();
734        total
735    }
736
737    pub fn reset_stats(&self) {
738        for s in &self.shards {
739            s.lock().reset_stats();
740        }
741        self.try_lock_misses.store(0, Ordering::Relaxed);
742    }
743
744    pub fn len(&self) -> usize {
745        self.shards.iter().map(|s| s.lock().len()).sum()
746    }
747
748    /// Live bytes across all shards.
749    pub fn used_bytes(&self) -> u64 {
750        self.shards.iter().map(|s| s.lock().used_bytes()).sum()
751    }
752
753    /// Governor entry point (S1E-003 step 2): evict at least `budget` bytes
754    /// across the shards, returning the bytes actually freed.
755    pub fn evict_reclaimable(&self, budget: u64) -> u64 {
756        let n = self.shards.len() as u64;
757        let mut freed = 0u64;
758        for (i, shard) in self.shards.iter().enumerate() {
759            if freed >= budget {
760                break;
761            }
762            let left = n - i as u64;
763            let target = (budget - freed).div_ceil(left);
764            freed += shard.lock().evict_reclaimable(target);
765        }
766        freed
767    }
768}
769
770impl crate::memory::Reclaimable for Sharded<DecodedPageCache> {
771    fn evict_reclaimable(&self, budget: u64) -> u64 {
772        <Sharded<DecodedPageCache>>::evict_reclaimable(self, budget)
773    }
774
775    fn reclaimable_bytes(&self) -> u64 {
776        self.used_bytes()
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use tempfile::tempdir;
784
785    fn page(hash: [u8; 32], epoch: u64, data: &[u8]) -> CachedPage {
786        CachedPage {
787            committed_epoch: Epoch(epoch),
788            content_hash: hash,
789            bytes: bytes::Bytes::copy_from_slice(data),
790        }
791    }
792
793    #[test]
794    fn mvcc_visibility() {
795        let mut cache = PageCache::new(1 << 20);
796        let hash = [1u8; 32];
797        cache.insert(page(hash, 3, b"v3"));
798        // A snapshot at epoch 2 must not see the epoch-3 page.
799        assert!(cache.get(&hash, Snapshot::at(Epoch(2))).is_none());
800        // A snapshot at epoch 3 does.
801        assert_eq!(
802            cache.get(&hash, Snapshot::at(Epoch(3))),
803            Some(bytes::Bytes::copy_from_slice(b"v3"))
804        );
805    }
806
807    #[test]
808    fn hit_miss_counters_track_lookups() {
809        let mut cache = PageCache::new(1 << 20);
810        let hash = [7u8; 32];
811        cache.insert(page(hash, 1, b"v"));
812        assert_eq!(
813            cache.stats(),
814            CacheStats {
815                hits: 0,
816                misses: 0,
817                try_lock_misses: 0
818            }
819        );
820
821        // Visible hit (get + try_get).
822        assert!(cache.get(&hash, Snapshot::at(Epoch(1))).is_some());
823        assert!(cache.try_get(&hash, Snapshot::at(Epoch(1))).is_some());
824        // Absent key ⇒ miss; present-but-too-new entry ⇒ miss.
825        assert!(cache.get(&[9u8; 32], Snapshot::at(Epoch(1))).is_none());
826        assert!(cache.get(&hash, Snapshot::at(Epoch(0))).is_none());
827
828        let s = cache.stats();
829        assert_eq!(
830            s,
831            CacheStats {
832                hits: 2,
833                misses: 2,
834                try_lock_misses: 0
835            }
836        );
837        assert!((s.hit_rate() - 0.5).abs() < 1e-9);
838
839        cache.reset_stats();
840        assert_eq!(
841            cache.stats(),
842            CacheStats {
843                hits: 0,
844                misses: 0,
845                try_lock_misses: 0
846            }
847        );
848        assert_eq!(cache.stats().hit_rate(), 0.0);
849    }
850
851    #[test]
852    fn content_addressed_replacement_ages_out_old() {
853        let mut cache = PageCache::new(1 << 20);
854        let hash_a = [1u8; 32];
855        let hash_b = [2u8; 32];
856        cache.insert(page(hash_a, 1, b"old"));
857        cache.insert(page(hash_b, 1, b"new"));
858        assert_eq!(cache.len(), 2);
859        assert!(cache.get(&hash_a, Snapshot::at(Epoch(1))).is_some());
860    }
861
862    #[test]
863    fn capacity_eviction_removes_cold_first() {
864        let mut cache = PageCache::new(10);
865        cache.insert(page([1u8; 32], 1, b"0123456789")); // exactly 10 bytes
866        assert_eq!(cache.used_bytes(), 10);
867        cache.insert(page([2u8; 32], 1, b"x")); // over → evicts the cold first page
868        assert_eq!(cache.used_bytes(), 1);
869        assert!(cache.get(&[1u8; 32], Snapshot::at(Epoch(1))).is_none());
870        assert!(cache.get(&[2u8; 32], Snapshot::at(Epoch(1))).is_some());
871    }
872
873    #[test]
874    fn frequency_keeps_hot_pages_alive() {
875        // Capacity for ~2 entries. Access A repeatedly; it should survive the
876        // insertion of B and C while the cold ones are evicted.
877        let mut cache = PageCache::new(2);
878        let a = [10u8; 32];
879        cache.insert(page(a, 1, b"A"));
880        // Bump A's frequency so the clock hand gives it second chances.
881        for _ in 0..5 {
882            let _ = cache.get(&a, Snapshot::at(Epoch(1)));
883        }
884        cache.insert(page([20u8; 32], 1, b"B"));
885        cache.insert(page([30u8; 32], 1, b"C"));
886        // A is hot → should still be resident.
887        assert!(
888            cache.get(&a, Snapshot::at(Epoch(1))).is_some(),
889            "hot page should survive eviction"
890        );
891    }
892
893    #[test]
894    fn persistent_cache_survives_restart() {
895        let dir = tempdir().unwrap();
896        let data = b"page-payload-1234";
897        let hash = [42u8; 32];
898
899        // First cache: insert + spill.
900        {
901            let mut cache = PageCache::new(1 << 20).with_persistence(dir.path().to_path_buf());
902            cache.insert(page(hash, 5, data));
903            cache.flush_to_disk();
904        }
905        // The backing file exists (named `<hex>.<epoch>`).
906        let backing = dir.path().join(format!("{}.{}", hex_key(&hash), 5));
907        assert!(backing.exists(), "cache file should be spilled");
908
909        // Second cache (simulating reopen): loads the spilled page.
910        let mut cache = PageCache::new(1 << 20).with_persistence(dir.path().to_path_buf());
911        let got = cache.get(&hash, Snapshot::at(Epoch(u64::MAX)));
912        assert_eq!(got, Some(bytes::Bytes::copy_from_slice(data)));
913    }
914
915    #[test]
916    fn try_get_does_not_block() {
917        let mut cache = PageCache::new(1 << 20);
918        let hash = [7u8; 32];
919        cache.insert(page(hash, 1, b"hi"));
920        let got = cache.try_get(&hash, Snapshot::at(Epoch(1)));
921        assert!(got.is_some());
922        let miss = cache.try_get(&[8u8; 32], Snapshot::at(Epoch(1)));
923        assert!(miss.is_none());
924    }
925
926    #[test]
927    fn hot_eviction_storm_does_not_orphan_entries() {
928        // Regression: when every entry is hot, the CLOCK fallback must evict the
929        // slot it actually inspected (not an innocent neighbor), otherwise orphaned
930        // entries accumulate and used_bytes drifts permanently above capacity.
931        let mut cache = PageCache::new(6); // room for ~3 two-byte pages
932        let mut next = 1u8;
933        for _ in 0..200 {
934            let key = [next; 32];
935            cache.insert(page(key, 1, b"aa")); // 2 bytes each
936                                               // Access the page repeatedly so it becomes hot (forces the fallback
937                                               // path as the clock hand sweeps only-hot entries on later evictions).
938            for _ in 0..4 {
939                let _ = cache.get(&key, Snapshot::at(Epoch(1)));
940            }
941            next = next.wrapping_add(1);
942        }
943        // After many hot evictions, used_bytes must respect capacity (no orphan
944        // drift) and every live ring slot must resolve to a map entry.
945        assert!(
946            cache.used_bytes() <= cache.capacity_bytes + 2, // +one page in flight
947            "used_bytes {} leaked past capacity {}",
948            cache.used_bytes(),
949            cache.capacity_bytes
950        );
951        // Consistency: ring and map agree (no orphans either way).
952        let mut ok = true;
953        for k in &cache.ring {
954            if !cache.map.contains_key(k) {
955                ok = false;
956            }
957        }
958        assert!(
959            ok,
960            "ring references a key absent from the map (orphan slot)"
961        );
962        assert_eq!(
963            cache.ring.len(),
964            cache.map.len(),
965            "ring/map size mismatch (orphaned entries or slots)"
966        );
967    }
968
969    #[test]
970    fn decoded_cache_hit_miss_counters() {
971        // Priority 14: the decoded-page cache reports hit/miss counts so a
972        // repeat scan's decode-skip rate is observable.
973        use crate::columnar::NativeColumn;
974        let mut cache = DecodedPageCache::new(1 << 20);
975        let key = [9u8; 32];
976        cache.insert(
977            key,
978            std::sync::Arc::new(NativeColumn::Int64 {
979                data: vec![1, 2, 3],
980                validity: vec![],
981            }),
982        );
983        // Two hits, one miss.
984        assert!(cache.try_get(&key).is_some());
985        assert!(cache.try_get(&key).is_some());
986        assert!(cache.try_get(&[0u8; 32]).is_none());
987        let s = cache.stats();
988        assert_eq!(s.hits, 2);
989        assert_eq!(s.misses, 1);
990        assert!(s.hit_rate() > 0.66 && s.hit_rate() < 0.67);
991        cache.reset_stats();
992        assert_eq!(cache.stats(), CacheStats::default());
993    }
994
995    #[test]
996    fn governor_attached_cache_reports_live_bytes() {
997        use crate::memory::{GovernorConfig, MemoryClass, MemoryGovernor};
998        let governor =
999            MemoryGovernor::new(GovernorConfig::new(1 << 20).with_reserved_floor(0)).unwrap();
1000        let mut cache =
1001            PageCache::new(1 << 20).with_governor(governor.clone(), MemoryClass::PageCache);
1002        cache.insert(page([1u8; 32], 1, b"0123456789"));
1003        cache.insert(page([2u8; 32], 1, b"abcde"));
1004        // The governor's per-class accounting tracks the cache's live bytes.
1005        assert_eq!(governor.usage(MemoryClass::PageCache), 15);
1006        assert_eq!(cache.used_bytes(), 15);
1007        // Capacity eviction shrinks the grant too.
1008        let mut cache = PageCache::new(10).with_governor(governor.clone(), MemoryClass::PageCache);
1009        cache.insert(page([3u8; 32], 1, b"0123456789"));
1010        cache.insert(page([4u8; 32], 1, b"x"));
1011        assert_eq!(cache.used_bytes(), 1);
1012        assert_eq!(governor.usage(MemoryClass::PageCache), 16);
1013        // Dropping the cache releases its reservation.
1014        drop(cache);
1015        assert_eq!(governor.usage(MemoryClass::PageCache), 15);
1016    }
1017
1018    #[test]
1019    fn governor_denial_sheds_coldest_entries() {
1020        use crate::memory::{GovernorConfig, MemoryClass, MemoryGovernor};
1021        // The governor is the binding constraint: 24 bytes node-wide, no
1022        // floor, while the cache's own capacity is huge.
1023        let governor = MemoryGovernor::new(GovernorConfig::new(24).with_reserved_floor(0)).unwrap();
1024        let mut cache =
1025            PageCache::new(1 << 20).with_governor(governor.clone(), MemoryClass::PageCache);
1026        for i in 0..8u8 {
1027            cache.insert(page([i; 32], 1, b"01234567")); // 8 bytes each
1028        }
1029        // The cache never exceeds what the governor granted (no OOM growth),
1030        // and the accounting is exact.
1031        assert!(cache.used_bytes() <= 24);
1032        assert_eq!(governor.usage(MemoryClass::PageCache), cache.used_bytes());
1033        // The coldest (first-inserted) pages were shed; recent ones survive.
1034        assert!(cache.get(&[7u8; 32], Snapshot::at(Epoch(1))).is_some());
1035    }
1036
1037    #[test]
1038    fn evict_reclaimable_frees_budget_and_keeps_accounting() {
1039        use crate::memory::{GovernorConfig, MemoryClass, MemoryGovernor};
1040        let governor =
1041            MemoryGovernor::new(GovernorConfig::new(1 << 20).with_reserved_floor(0)).unwrap();
1042        let mut cache =
1043            PageCache::new(1 << 20).with_governor(governor.clone(), MemoryClass::PageCache);
1044        for i in 0..4u8 {
1045            cache.insert(page([i; 32], 1, b"0123456789")); // 10 bytes each
1046        }
1047        assert_eq!(governor.usage(MemoryClass::PageCache), 40);
1048        let freed = cache.evict_reclaimable(25);
1049        assert!(freed >= 25, "freed {freed}");
1050        assert_eq!(cache.used_bytes(), 40 - freed);
1051        assert_eq!(governor.usage(MemoryClass::PageCache), cache.used_bytes());
1052        // Evicting more than held frees everything and stays consistent.
1053        let remaining = cache.used_bytes();
1054        let freed = cache.evict_reclaimable(1 << 20);
1055        assert_eq!(freed, remaining);
1056        assert_eq!(cache.used_bytes(), 0);
1057        assert_eq!(governor.usage(MemoryClass::PageCache), 0);
1058    }
1059
1060    #[test]
1061    fn decoded_cache_governor_reporting() {
1062        use crate::columnar::NativeColumn;
1063        use crate::memory::{GovernorConfig, MemoryClass, MemoryGovernor};
1064        let governor =
1065            MemoryGovernor::new(GovernorConfig::new(1 << 20).with_reserved_floor(0)).unwrap();
1066        let mut cache = DecodedPageCache::new(1 << 20)
1067            .with_governor(governor.clone(), MemoryClass::DecodedCache);
1068        cache.insert(
1069            [9u8; 32],
1070            std::sync::Arc::new(NativeColumn::Int64 {
1071                data: vec![1, 2, 3],
1072                validity: vec![],
1073            }),
1074        );
1075        assert_eq!(
1076            governor.usage(MemoryClass::DecodedCache),
1077            cache.used_bytes()
1078        );
1079        assert!(cache.used_bytes() > 0);
1080        let held = cache.used_bytes();
1081        let freed = cache.evict_reclaimable(u64::MAX);
1082        assert_eq!(freed, held);
1083        assert_eq!(cache.used_bytes(), 0);
1084        assert_eq!(governor.usage(MemoryClass::DecodedCache), 0);
1085    }
1086}