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    /// Lookups served from the decoded cache (skipped decode).
368    hits: AtomicU64,
369    /// Lookups that missed (caller decoded the page).
370    misses: AtomicU64,
371}
372
373impl DecodedPageCache {
374    pub fn new(capacity_bytes: u64) -> Self {
375        Self {
376            map: HashMap::new(),
377            ring: Vec::new(),
378            hand: 0,
379            capacity_bytes,
380            used_bytes: 0,
381            hits: AtomicU64::new(0),
382            misses: AtomicU64::new(0),
383        }
384    }
385
386    /// Cumulative hit/miss counts since construction (or the last
387    /// [`reset_stats`](Self::reset_stats)).
388    pub fn stats(&self) -> CacheStats {
389        CacheStats {
390            hits: self.hits.load(Ordering::Relaxed),
391            misses: self.misses.load(Ordering::Relaxed),
392        }
393    }
394
395    /// Zero the hit/miss counters.
396    pub fn reset_stats(&self) {
397        self.hits.store(0, Ordering::Relaxed);
398        self.misses.store(0, Ordering::Relaxed);
399    }
400
401    /// Non-blocking probe used by the parallel scan path (`&self`, no write
402    /// lock) — returns the cached decoded page on hit, `None` on miss.
403    pub fn try_get(&self, key: &[u8; 32]) -> Option<std::sync::Arc<crate::columnar::NativeColumn>> {
404        match self.map.get(key).cloned() {
405            Some(v) => {
406                self.hits.fetch_add(1, Ordering::Relaxed);
407                Some(v)
408            }
409            None => {
410                self.misses.fetch_add(1, Ordering::Relaxed);
411                None
412            }
413        }
414    }
415
416    /// Insert a decoded page (replaces any entry with the same key). Evicts
417    /// cold entries (CLOCK) when over capacity.
418    pub fn insert(&mut self, key: [u8; 32], col: std::sync::Arc<crate::columnar::NativeColumn>) {
419        let size = col.approx_bytes();
420        let was_new = !self.map.contains_key(&key);
421        if let Some(old) = self.map.insert(key, col) {
422            self.used_bytes = self.used_bytes.saturating_sub(old.approx_bytes());
423        } else if was_new {
424            self.ring.push(key);
425        }
426        self.used_bytes += size;
427        self.evict_if_needed();
428    }
429
430    fn evict_if_needed(&mut self) {
431        // Simple CLOCK eviction: while over capacity, evict the slot under the
432        // hand and advance. Decoded pages are cheap to rebuild (decode is fast,
433        // especially under LZ4), so a crude size-bounded policy is sufficient —
434        // the goal is just to cap memory for very hot repeat-scan workloads.
435        while self.used_bytes > self.capacity_bytes && !self.ring.is_empty() {
436            let idx = self.hand.min(self.ring.len() - 1);
437            let key = self.ring.swap_remove(idx);
438            if let Some(col) = self.map.remove(&key) {
439                self.used_bytes = self.used_bytes.saturating_sub(col.approx_bytes());
440            }
441            if self.ring.is_empty() {
442                self.hand = 0;
443            } else {
444                self.hand %= self.ring.len();
445            }
446        }
447    }
448
449    pub fn len(&self) -> usize {
450        self.map.len()
451    }
452
453    pub fn is_empty(&self) -> bool {
454        self.map.is_empty()
455    }
456
457    pub fn used_bytes(&self) -> u64 {
458        self.used_bytes
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use tempfile::tempdir;
466
467    fn page(hash: [u8; 32], epoch: u64, data: &[u8]) -> CachedPage {
468        CachedPage {
469            committed_epoch: Epoch(epoch),
470            content_hash: hash,
471            bytes: bytes::Bytes::copy_from_slice(data),
472        }
473    }
474
475    #[test]
476    fn mvcc_visibility() {
477        let mut cache = PageCache::new(1 << 20);
478        let hash = [1u8; 32];
479        cache.insert(page(hash, 3, b"v3"));
480        // A snapshot at epoch 2 must not see the epoch-3 page.
481        assert!(cache.get(&hash, Snapshot::at(Epoch(2))).is_none());
482        // A snapshot at epoch 3 does.
483        assert_eq!(
484            cache.get(&hash, Snapshot::at(Epoch(3))),
485            Some(bytes::Bytes::copy_from_slice(b"v3"))
486        );
487    }
488
489    #[test]
490    fn hit_miss_counters_track_lookups() {
491        let mut cache = PageCache::new(1 << 20);
492        let hash = [7u8; 32];
493        cache.insert(page(hash, 1, b"v"));
494        assert_eq!(cache.stats(), CacheStats { hits: 0, misses: 0 });
495
496        // Visible hit (get + try_get).
497        assert!(cache.get(&hash, Snapshot::at(Epoch(1))).is_some());
498        assert!(cache.try_get(&hash, Snapshot::at(Epoch(1))).is_some());
499        // Absent key ⇒ miss; present-but-too-new entry ⇒ miss.
500        assert!(cache.get(&[9u8; 32], Snapshot::at(Epoch(1))).is_none());
501        assert!(cache.get(&hash, Snapshot::at(Epoch(0))).is_none());
502
503        let s = cache.stats();
504        assert_eq!(s, CacheStats { hits: 2, misses: 2 });
505        assert!((s.hit_rate() - 0.5).abs() < 1e-9);
506
507        cache.reset_stats();
508        assert_eq!(cache.stats(), CacheStats { hits: 0, misses: 0 });
509        assert_eq!(cache.stats().hit_rate(), 0.0);
510    }
511
512    #[test]
513    fn content_addressed_replacement_ages_out_old() {
514        let mut cache = PageCache::new(1 << 20);
515        let hash_a = [1u8; 32];
516        let hash_b = [2u8; 32];
517        cache.insert(page(hash_a, 1, b"old"));
518        cache.insert(page(hash_b, 1, b"new"));
519        assert_eq!(cache.len(), 2);
520        assert!(cache.get(&hash_a, Snapshot::at(Epoch(1))).is_some());
521    }
522
523    #[test]
524    fn capacity_eviction_removes_cold_first() {
525        let mut cache = PageCache::new(10);
526        cache.insert(page([1u8; 32], 1, b"0123456789")); // exactly 10 bytes
527        assert_eq!(cache.used_bytes(), 10);
528        cache.insert(page([2u8; 32], 1, b"x")); // over → evicts the cold first page
529        assert_eq!(cache.used_bytes(), 1);
530        assert!(cache.get(&[1u8; 32], Snapshot::at(Epoch(1))).is_none());
531        assert!(cache.get(&[2u8; 32], Snapshot::at(Epoch(1))).is_some());
532    }
533
534    #[test]
535    fn frequency_keeps_hot_pages_alive() {
536        // Capacity for ~2 entries. Access A repeatedly; it should survive the
537        // insertion of B and C while the cold ones are evicted.
538        let mut cache = PageCache::new(2);
539        let a = [10u8; 32];
540        cache.insert(page(a, 1, b"A"));
541        // Bump A's frequency so the clock hand gives it second chances.
542        for _ in 0..5 {
543            let _ = cache.get(&a, Snapshot::at(Epoch(1)));
544        }
545        cache.insert(page([20u8; 32], 1, b"B"));
546        cache.insert(page([30u8; 32], 1, b"C"));
547        // A is hot → should still be resident.
548        assert!(
549            cache.get(&a, Snapshot::at(Epoch(1))).is_some(),
550            "hot page should survive eviction"
551        );
552    }
553
554    #[test]
555    fn persistent_cache_survives_restart() {
556        let dir = tempdir().unwrap();
557        let data = b"page-payload-1234";
558        let hash = [42u8; 32];
559
560        // First cache: insert + spill.
561        {
562            let mut cache = PageCache::new(1 << 20).with_persistence(dir.path().to_path_buf());
563            cache.insert(page(hash, 5, data));
564            cache.flush_to_disk();
565        }
566        // The backing file exists (named `<hex>.<epoch>`).
567        let backing = dir.path().join(format!("{}.{}", hex_key(&hash), 5));
568        assert!(backing.exists(), "cache file should be spilled");
569
570        // Second cache (simulating reopen): loads the spilled page.
571        let mut cache = PageCache::new(1 << 20).with_persistence(dir.path().to_path_buf());
572        let got = cache.get(&hash, Snapshot::at(Epoch(u64::MAX)));
573        assert_eq!(got, Some(bytes::Bytes::copy_from_slice(data)));
574    }
575
576    #[test]
577    fn try_get_does_not_block() {
578        let mut cache = PageCache::new(1 << 20);
579        let hash = [7u8; 32];
580        cache.insert(page(hash, 1, b"hi"));
581        let got = cache.try_get(&hash, Snapshot::at(Epoch(1)));
582        assert!(got.is_some());
583        let miss = cache.try_get(&[8u8; 32], Snapshot::at(Epoch(1)));
584        assert!(miss.is_none());
585    }
586
587    #[test]
588    fn hot_eviction_storm_does_not_orphan_entries() {
589        // Regression: when every entry is hot, the CLOCK fallback must evict the
590        // slot it actually inspected (not an innocent neighbor), otherwise orphaned
591        // entries accumulate and used_bytes drifts permanently above capacity.
592        let mut cache = PageCache::new(6); // room for ~3 two-byte pages
593        let mut next = 1u8;
594        for _ in 0..200 {
595            let key = [next; 32];
596            cache.insert(page(key, 1, b"aa")); // 2 bytes each
597                                               // Access the page repeatedly so it becomes hot (forces the fallback
598                                               // path as the clock hand sweeps only-hot entries on later evictions).
599            for _ in 0..4 {
600                let _ = cache.get(&key, Snapshot::at(Epoch(1)));
601            }
602            next = next.wrapping_add(1);
603        }
604        // After many hot evictions, used_bytes must respect capacity (no orphan
605        // drift) and every live ring slot must resolve to a map entry.
606        assert!(
607            cache.used_bytes() <= cache.capacity_bytes + 2, // +one page in flight
608            "used_bytes {} leaked past capacity {}",
609            cache.used_bytes(),
610            cache.capacity_bytes
611        );
612        // Consistency: ring and map agree (no orphans either way).
613        let mut ok = true;
614        for k in &cache.ring {
615            if !cache.map.contains_key(k) {
616                ok = false;
617            }
618        }
619        assert!(
620            ok,
621            "ring references a key absent from the map (orphan slot)"
622        );
623        assert_eq!(
624            cache.ring.len(),
625            cache.map.len(),
626            "ring/map size mismatch (orphaned entries or slots)"
627        );
628    }
629
630    #[test]
631    fn decoded_cache_hit_miss_counters() {
632        // Priority 14: the decoded-page cache reports hit/miss counts so a
633        // repeat scan's decode-skip rate is observable.
634        use crate::columnar::NativeColumn;
635        let mut cache = DecodedPageCache::new(1 << 20);
636        let key = [9u8; 32];
637        cache.insert(
638            key,
639            std::sync::Arc::new(NativeColumn::Int64 {
640                data: vec![1, 2, 3],
641                validity: vec![],
642            }),
643        );
644        // Two hits, one miss.
645        assert!(cache.try_get(&key).is_some());
646        assert!(cache.try_get(&key).is_some());
647        assert!(cache.try_get(&[0u8; 32]).is_none());
648        let s = cache.stats();
649        assert_eq!(s.hits, 2);
650        assert_eq!(s.misses, 1);
651        assert!(s.hit_rate() > 0.66 && s.hit_rate() < 0.67);
652        cache.reset_stats();
653        assert_eq!(cache.stats(), CacheStats::default());
654    }
655}