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
18use crate::epoch::{Epoch, Snapshot};
19use crate::page::CachedPage;
20use std::collections::HashMap;
21use std::path::PathBuf;
22use std::sync::atomic::{AtomicU64, Ordering};
23
24/// Cumulative page-cache access counters (Priority 14: hit visibility). A *hit*
25/// is a lookup that returned a page visible to the snapshot; a *miss* is a
26/// lookup that found nothing or an entry too new for the snapshot (the caller
27/// then reads from disk).
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct CacheStats {
30    pub hits: u64,
31    pub misses: u64,
32}
33
34impl CacheStats {
35    /// Fraction of lookups served from cache in `[0, 1]` (`0` when never used).
36    pub fn hit_rate(&self) -> f64 {
37        let total = self.hits + self.misses;
38        if total == 0 {
39            0.0
40        } else {
41            self.hits as f64 / total as f64
42        }
43    }
44}
45
46/// Bounded, MVCC-safe page cache with frequency-aware CLOCK eviction and an
47/// optional persistent backing directory.
48pub struct PageCache {
49    map: HashMap<[u8; 32], Entry>,
50    /// Clock hand indexes into `ring`; `ring` holds every live key once.
51    ring: Vec<[u8; 32]>,
52    hand: usize,
53    capacity_bytes: u64,
54    used_bytes: u64,
55    /// Backing directory for the persistent cache (`_cache/`). Entries are
56    /// spilled/loaded as `<hex(key)>` files (raw on-disk bytes).
57    dir: Option<PathBuf>,
58    persistent: bool,
59    /// Lookups served from cache (visible to the snapshot).
60    hits: AtomicU64,
61    /// Lookups that found nothing visible (caller falls through to disk).
62    misses: AtomicU64,
63}
64
65struct Entry {
66    bytes: bytes::Bytes,
67    epoch: Epoch,
68    freq: u8,
69}
70
71const FREQ_MAX: u8 = 3;
72
73impl PageCache {
74    pub fn new(capacity_bytes: u64) -> Self {
75        Self {
76            map: HashMap::new(),
77            ring: Vec::new(),
78            hand: 0,
79            capacity_bytes,
80            used_bytes: 0,
81            dir: None,
82            persistent: false,
83            hits: AtomicU64::new(0),
84            misses: AtomicU64::new(0),
85        }
86    }
87
88    /// Cumulative hit/miss counts since construction (or the last
89    /// [`reset_stats`](Self::reset_stats)). Cheap (`Relaxed` atomic loads).
90    pub fn stats(&self) -> CacheStats {
91        CacheStats {
92            hits: self.hits.load(Ordering::Relaxed),
93            misses: self.misses.load(Ordering::Relaxed),
94        }
95    }
96
97    /// Zero the hit/miss counters (e.g. to measure a single query's locality).
98    pub fn reset_stats(&self) {
99        self.hits.store(0, Ordering::Relaxed);
100        self.misses.store(0, Ordering::Relaxed);
101    }
102
103    /// Enable persistence: load any cached pages from `<dir>` and spill future
104    /// evictions/inserts there. Files are raw page bytes (ciphertext when the
105    /// table is encrypted).
106    pub fn with_persistence(mut self, dir: PathBuf) -> Self {
107        self.persistent = true;
108        self.dir = Some(dir.clone());
109        self.load_from_disk();
110        self
111    }
112
113    /// Insert a page (replaces any entry with the same key). Spills to the
114    /// persistent backing directory when enabled.
115    pub fn insert(&mut self, page: CachedPage) {
116        let key = page.content_hash;
117        let size = page.bytes.len() as u64;
118        let epoch = page.committed_epoch;
119        let was_new = !self.map.contains_key(&key);
120        if let Some(old) = self.map.insert(
121            key,
122            Entry {
123                bytes: page.bytes.clone(),
124                epoch,
125                freq: 1,
126            },
127        ) {
128            self.used_bytes = self.used_bytes.saturating_sub(old.bytes.len() as u64);
129        } else if was_new {
130            self.ring.push(key);
131        }
132        self.used_bytes += size;
133        self.evict_if_needed();
134    }
135
136    /// Fetch the page visible to `snapshot` (the entry's committed epoch must be
137    /// `<= snapshot.epoch`), promoting its frequency. O(1).
138    pub fn get(&mut self, content_hash: &[u8; 32], snapshot: Snapshot) -> Option<bytes::Bytes> {
139        let hit = self
140            .map
141            .get_mut(content_hash)
142            .filter(|e| e.epoch <= snapshot.epoch)
143            .map(|entry| {
144                if entry.freq < FREQ_MAX {
145                    entry.freq += 1;
146                }
147                entry.bytes.clone()
148            });
149        let counter = if hit.is_some() {
150            &self.hits
151        } else {
152            &self.misses
153        };
154        counter.fetch_add(1, Ordering::Relaxed);
155        hit
156    }
157
158    /// Non-blocking probe used by the parallel (rayon) read path: returns a hit
159    /// without contending on a write lock when one is held. `Some` on hit;
160    /// `None` on miss (or if the value is not visible to `snapshot`). The
161    /// caller treats any `None` as "fall through to disk".
162    pub fn try_get(&self, content_hash: &[u8; 32], snapshot: Snapshot) -> Option<bytes::Bytes> {
163        let hit = self.map.get(content_hash).and_then(|e| {
164            if e.epoch <= snapshot.epoch {
165                Some(e.bytes.clone())
166            } else {
167                None
168            }
169        });
170        let counter = if hit.is_some() {
171            &self.hits
172        } else {
173            &self.misses
174        };
175        counter.fetch_add(1, Ordering::Relaxed);
176        hit
177    }
178
179    pub fn used_bytes(&self) -> u64 {
180        self.used_bytes
181    }
182
183    pub fn len(&self) -> usize {
184        self.map.len()
185    }
186
187    pub fn is_empty(&self) -> bool {
188        self.map.is_empty()
189    }
190
191    /// Flush all live entries to the persistent backing dir (best-effort).
192    pub fn flush_to_disk(&self) {
193        if !self.persistent {
194            return;
195        }
196        for (key, entry) in &self.map {
197            self.spill(key, entry.epoch, &entry.bytes);
198        }
199    }
200
201    fn spill(&self, key: &[u8; 32], epoch: Epoch, bytes: &[u8]) {
202        if !self.persistent {
203            return;
204        }
205        let Some(dir) = &self.dir else { return };
206        let _ = std::fs::create_dir_all(dir);
207        let hex = hex_key(key);
208        // Embed the committed epoch in the filename (`<hex>.<epoch>`) so it can
209        // be restored on reload instead of defaulting to a maximal epoch —
210        // otherwise reloaded pages are invisible to ordinary snapshots and the
211        // persistent tier is effectively dead after reopen. The page bytes stay
212        // raw (ciphertext when the table is encrypted).
213        let path = dir.join(format!("{hex}.{}", epoch.0));
214        let tmp = dir.join(format!("{hex}.{}.tmp", epoch.0));
215        if std::fs::write(&tmp, bytes).is_ok() {
216            let _ = std::fs::rename(&tmp, &path);
217        }
218    }
219
220    fn load_from_disk(&mut self) {
221        let Some(dir) = &self.dir else { return };
222        let entries = match std::fs::read_dir(dir) {
223            Ok(e) => e,
224            Err(_) => return,
225        };
226        for entry in entries.flatten() {
227            let name = entry.file_name();
228            let Some(s) = name.to_str() else { continue };
229            // Skip in-progress temp files.
230            if s.ends_with(".tmp") {
231                continue;
232            }
233            // New format: "<64hex>.<epoch>". Legacy raw files ("<64hex>" with
234            // no dot) stay visible to every snapshot.
235            let (key, epoch) = match s.rsplit_once('.') {
236                Some((hex, suffix)) => {
237                    let key = match decode_hex_key(hex) {
238                        Some(k) => k,
239                        None => continue,
240                    };
241                    let epoch = match suffix.parse::<u64>() {
242                        Ok(e) => Epoch(e),
243                        Err(_) => continue,
244                    };
245                    (key, epoch)
246                }
247                None => {
248                    let key = match decode_hex_key(s) {
249                        Some(k) => k,
250                        None => continue,
251                    };
252                    (key, Epoch(u64::MAX))
253                }
254            };
255            if self.map.contains_key(&key) {
256                continue;
257            }
258            let Ok(bytes) = std::fs::read(entry.path()) else {
259                continue;
260            };
261            let size = bytes.len() as u64;
262            if self.used_bytes.saturating_add(size) > self.capacity_bytes {
263                break; // don't exceed capacity on load
264            }
265            self.map.insert(
266                key,
267                Entry {
268                    bytes: bytes::Bytes::from(bytes),
269                    epoch,
270                    freq: 0,
271                },
272            );
273            self.ring.push(key);
274            self.used_bytes += size;
275        }
276    }
277
278    /// Frequency-aware CLOCK eviction: advance the hand, evicting the first cold
279    /// (freq == 0) entry; decrement hot entries as they're passed (second chance).
280    fn evict_if_needed(&mut self) {
281        if self.ring.is_empty() {
282            return;
283        }
284        while self.used_bytes > self.capacity_bytes {
285            // Find a cold slot. Bounded by ~2× live entries per eviction storm.
286            let mut scanned = 0;
287            let len = self.ring.len();
288            loop {
289                let key = self.ring[self.hand];
290                let Some(entry) = self.map.get_mut(&key) else {
291                    // Stale ring slot (key removed); reclaim it.
292                    self.ring.swap_remove(self.hand);
293                    if self.hand >= self.ring.len() && !self.ring.is_empty() {
294                        self.hand %= self.ring.len();
295                    }
296                    break;
297                };
298                if entry.freq == 0 {
299                    // Evict this cold entry.
300                    let removed = self.map.remove(&key).expect("entry present");
301                    self.spill(&key, removed.epoch, &removed.bytes);
302                    self.used_bytes = self.used_bytes.saturating_sub(removed.bytes.len() as u64);
303                    self.ring.swap_remove(self.hand);
304                    if !self.ring.is_empty() {
305                        self.hand %= self.ring.len();
306                    }
307                    break;
308                } else {
309                    entry.freq -= 1;
310                    scanned += 1;
311                    if scanned > len * 2 {
312                        // Everything is hot; evict the current slot outright. The
313                        // hand has NOT been advanced yet, so this removes the slot
314                        // we just inspected (not an innocent neighbor).
315                        let removed = self.map.remove(&key).expect("entry present");
316                        self.spill(&key, removed.epoch, &removed.bytes);
317                        self.used_bytes =
318                            self.used_bytes.saturating_sub(removed.bytes.len() as u64);
319                        self.ring.swap_remove(self.hand);
320                        if !self.ring.is_empty() {
321                            self.hand %= self.ring.len();
322                        }
323                        break;
324                    }
325                    self.hand = (self.hand + 1) % len;
326                }
327            }
328            if self.ring.is_empty() {
329                break;
330            }
331        }
332    }
333}
334
335fn hex_key(key: &[u8; 32]) -> String {
336    let mut s = String::with_capacity(64);
337    for b in key {
338        s.push_str(&format!("{b:02x}"));
339    }
340    s
341}
342
343fn decode_hex_key(hex: &str) -> Option<[u8; 32]> {
344    if hex.len() != 64 {
345        return None;
346    }
347    let mut out = [0u8; 32];
348    for i in 0..32 {
349        out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
350    }
351    Some(out)
352}
353
354/// Bounded LRU/CLOCK cache of **decoded** columnar pages (Phase 15.4). The
355/// [`PageCache`] above holds raw (ciphertext) page bytes; this second layer
356/// caches the post-decompress, post-decrypt typed page so a repeat scan skips
357/// decode entirely. Pages are immutable per `(run_id, column_id, page_seq)`
358/// identity (keyed via [`crate::sorted_run::page_cache_key`]), so there is no
359/// MVCC/invalidation concern — runs never change, and a rewritten page lives in
360/// a different run (different id) and simply misses here.
361pub struct DecodedPageCache {
362    map: HashMap<[u8; 32], std::sync::Arc<crate::columnar::NativeColumn>>,
363    ring: Vec<[u8; 32]>,
364    hand: usize,
365    capacity_bytes: u64,
366    used_bytes: u64,
367}
368
369impl DecodedPageCache {
370    pub fn new(capacity_bytes: u64) -> Self {
371        Self {
372            map: HashMap::new(),
373            ring: Vec::new(),
374            hand: 0,
375            capacity_bytes,
376            used_bytes: 0,
377        }
378    }
379
380    /// Non-blocking probe used by the parallel scan path (`&self`, no write
381    /// lock) — returns the cached decoded page on hit, `None` on miss.
382    pub fn try_get(&self, key: &[u8; 32]) -> Option<std::sync::Arc<crate::columnar::NativeColumn>> {
383        self.map.get(key).cloned()
384    }
385
386    /// Insert a decoded page (replaces any entry with the same key). Evicts
387    /// cold entries (CLOCK) when over capacity.
388    pub fn insert(&mut self, key: [u8; 32], col: std::sync::Arc<crate::columnar::NativeColumn>) {
389        let size = col.approx_bytes();
390        let was_new = !self.map.contains_key(&key);
391        if let Some(old) = self.map.insert(key, col) {
392            self.used_bytes = self.used_bytes.saturating_sub(old.approx_bytes());
393        } else if was_new {
394            self.ring.push(key);
395        }
396        self.used_bytes += size;
397        self.evict_if_needed();
398    }
399
400    fn evict_if_needed(&mut self) {
401        // Simple CLOCK eviction: while over capacity, evict the slot under the
402        // hand and advance. Decoded pages are cheap to rebuild (decode is fast,
403        // especially under LZ4), so a crude size-bounded policy is sufficient —
404        // the goal is just to cap memory for very hot repeat-scan workloads.
405        while self.used_bytes > self.capacity_bytes && !self.ring.is_empty() {
406            let idx = self.hand.min(self.ring.len() - 1);
407            let key = self.ring.swap_remove(idx);
408            if let Some(col) = self.map.remove(&key) {
409                self.used_bytes = self.used_bytes.saturating_sub(col.approx_bytes());
410            }
411            if self.ring.is_empty() {
412                self.hand = 0;
413            } else {
414                self.hand %= self.ring.len();
415            }
416        }
417    }
418
419    pub fn len(&self) -> usize {
420        self.map.len()
421    }
422
423    pub fn is_empty(&self) -> bool {
424        self.map.is_empty()
425    }
426
427    pub fn used_bytes(&self) -> u64 {
428        self.used_bytes
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use tempfile::tempdir;
436
437    fn page(hash: [u8; 32], epoch: u64, data: &[u8]) -> CachedPage {
438        CachedPage {
439            committed_epoch: Epoch(epoch),
440            content_hash: hash,
441            bytes: bytes::Bytes::copy_from_slice(data),
442        }
443    }
444
445    #[test]
446    fn mvcc_visibility() {
447        let mut cache = PageCache::new(1 << 20);
448        let hash = [1u8; 32];
449        cache.insert(page(hash, 3, b"v3"));
450        // A snapshot at epoch 2 must not see the epoch-3 page.
451        assert!(cache.get(&hash, Snapshot::at(Epoch(2))).is_none());
452        // A snapshot at epoch 3 does.
453        assert_eq!(
454            cache.get(&hash, Snapshot::at(Epoch(3))),
455            Some(bytes::Bytes::copy_from_slice(b"v3"))
456        );
457    }
458
459    #[test]
460    fn hit_miss_counters_track_lookups() {
461        let mut cache = PageCache::new(1 << 20);
462        let hash = [7u8; 32];
463        cache.insert(page(hash, 1, b"v"));
464        assert_eq!(cache.stats(), CacheStats { hits: 0, misses: 0 });
465
466        // Visible hit (get + try_get).
467        assert!(cache.get(&hash, Snapshot::at(Epoch(1))).is_some());
468        assert!(cache.try_get(&hash, Snapshot::at(Epoch(1))).is_some());
469        // Absent key ⇒ miss; present-but-too-new entry ⇒ miss.
470        assert!(cache.get(&[9u8; 32], Snapshot::at(Epoch(1))).is_none());
471        assert!(cache.get(&hash, Snapshot::at(Epoch(0))).is_none());
472
473        let s = cache.stats();
474        assert_eq!(s, CacheStats { hits: 2, misses: 2 });
475        assert!((s.hit_rate() - 0.5).abs() < 1e-9);
476
477        cache.reset_stats();
478        assert_eq!(cache.stats(), CacheStats { hits: 0, misses: 0 });
479        assert_eq!(cache.stats().hit_rate(), 0.0);
480    }
481
482    #[test]
483    fn content_addressed_replacement_ages_out_old() {
484        let mut cache = PageCache::new(1 << 20);
485        let hash_a = [1u8; 32];
486        let hash_b = [2u8; 32];
487        cache.insert(page(hash_a, 1, b"old"));
488        cache.insert(page(hash_b, 1, b"new"));
489        assert_eq!(cache.len(), 2);
490        assert!(cache.get(&hash_a, Snapshot::at(Epoch(1))).is_some());
491    }
492
493    #[test]
494    fn capacity_eviction_removes_cold_first() {
495        let mut cache = PageCache::new(10);
496        cache.insert(page([1u8; 32], 1, b"0123456789")); // exactly 10 bytes
497        assert_eq!(cache.used_bytes(), 10);
498        cache.insert(page([2u8; 32], 1, b"x")); // over → evicts the cold first page
499        assert_eq!(cache.used_bytes(), 1);
500        assert!(cache.get(&[1u8; 32], Snapshot::at(Epoch(1))).is_none());
501        assert!(cache.get(&[2u8; 32], Snapshot::at(Epoch(1))).is_some());
502    }
503
504    #[test]
505    fn frequency_keeps_hot_pages_alive() {
506        // Capacity for ~2 entries. Access A repeatedly; it should survive the
507        // insertion of B and C while the cold ones are evicted.
508        let mut cache = PageCache::new(2);
509        let a = [10u8; 32];
510        cache.insert(page(a, 1, b"A"));
511        // Bump A's frequency so the clock hand gives it second chances.
512        for _ in 0..5 {
513            let _ = cache.get(&a, Snapshot::at(Epoch(1)));
514        }
515        cache.insert(page([20u8; 32], 1, b"B"));
516        cache.insert(page([30u8; 32], 1, b"C"));
517        // A is hot → should still be resident.
518        assert!(
519            cache.get(&a, Snapshot::at(Epoch(1))).is_some(),
520            "hot page should survive eviction"
521        );
522    }
523
524    #[test]
525    fn persistent_cache_survives_restart() {
526        let dir = tempdir().unwrap();
527        let data = b"page-payload-1234";
528        let hash = [42u8; 32];
529
530        // First cache: insert + spill.
531        {
532            let mut cache = PageCache::new(1 << 20).with_persistence(dir.path().to_path_buf());
533            cache.insert(page(hash, 5, data));
534            cache.flush_to_disk();
535        }
536        // The backing file exists (named `<hex>.<epoch>`).
537        let backing = dir.path().join(format!("{}.{}", hex_key(&hash), 5));
538        assert!(backing.exists(), "cache file should be spilled");
539
540        // Second cache (simulating reopen): loads the spilled page.
541        let mut cache = PageCache::new(1 << 20).with_persistence(dir.path().to_path_buf());
542        let got = cache.get(&hash, Snapshot::at(Epoch(u64::MAX)));
543        assert_eq!(got, Some(bytes::Bytes::copy_from_slice(data)));
544    }
545
546    #[test]
547    fn try_get_does_not_block() {
548        let mut cache = PageCache::new(1 << 20);
549        let hash = [7u8; 32];
550        cache.insert(page(hash, 1, b"hi"));
551        let got = cache.try_get(&hash, Snapshot::at(Epoch(1)));
552        assert!(got.is_some());
553        let miss = cache.try_get(&[8u8; 32], Snapshot::at(Epoch(1)));
554        assert!(miss.is_none());
555    }
556
557    #[test]
558    fn hot_eviction_storm_does_not_orphan_entries() {
559        // Regression: when every entry is hot, the CLOCK fallback must evict the
560        // slot it actually inspected (not an innocent neighbor), otherwise orphaned
561        // entries accumulate and used_bytes drifts permanently above capacity.
562        let mut cache = PageCache::new(6); // room for ~3 two-byte pages
563        let mut next = 1u8;
564        for _ in 0..200 {
565            let key = [next; 32];
566            cache.insert(page(key, 1, b"aa")); // 2 bytes each
567                                               // Access the page repeatedly so it becomes hot (forces the fallback
568                                               // path as the clock hand sweeps only-hot entries on later evictions).
569            for _ in 0..4 {
570                let _ = cache.get(&key, Snapshot::at(Epoch(1)));
571            }
572            next = next.wrapping_add(1);
573        }
574        // After many hot evictions, used_bytes must respect capacity (no orphan
575        // drift) and every live ring slot must resolve to a map entry.
576        assert!(
577            cache.used_bytes() <= cache.capacity_bytes + 2, // +one page in flight
578            "used_bytes {} leaked past capacity {}",
579            cache.used_bytes(),
580            cache.capacity_bytes
581        );
582        // Consistency: ring and map agree (no orphans either way).
583        let mut ok = true;
584        for k in &cache.ring {
585            if !cache.map.contains_key(k) {
586                ok = false;
587            }
588        }
589        assert!(
590            ok,
591            "ring references a key absent from the map (orphan slot)"
592        );
593        assert_eq!(
594            cache.ring.len(),
595            cache.map.len(),
596            "ring/map size mismatch (orphaned entries or slots)"
597        );
598    }
599}