1use crate::epoch::{Epoch, Snapshot};
19use crate::page::CachedPage;
20use std::collections::HashMap;
21use std::path::PathBuf;
22use std::sync::atomic::{AtomicU64, Ordering};
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct CacheStats {
30 pub hits: u64,
31 pub misses: u64,
32}
33
34impl CacheStats {
35 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
46pub struct PageCache {
49 map: HashMap<[u8; 32], Entry>,
50 ring: Vec<[u8; 32]>,
52 hand: usize,
53 capacity_bytes: u64,
54 used_bytes: u64,
55 dir: Option<PathBuf>,
58 persistent: bool,
59 hits: AtomicU64,
61 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 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 pub fn reset_stats(&self) {
99 self.hits.store(0, Ordering::Relaxed);
100 self.misses.store(0, Ordering::Relaxed);
101 }
102
103 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 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 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 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 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 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 if s.ends_with(".tmp") {
231 continue;
232 }
233 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; }
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 fn evict_if_needed(&mut self) {
281 if self.ring.is_empty() {
282 return;
283 }
284 while self.used_bytes > self.capacity_bytes {
285 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 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 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 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
354pub 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 hits: AtomicU64,
369 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 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 pub fn reset_stats(&self) {
397 self.hits.store(0, Ordering::Relaxed);
398 self.misses.store(0, Ordering::Relaxed);
399 }
400
401 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 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 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 assert!(cache.get(&hash, Snapshot::at(Epoch(2))).is_none());
482 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 assert!(cache.get(&hash, Snapshot::at(Epoch(1))).is_some());
498 assert!(cache.try_get(&hash, Snapshot::at(Epoch(1))).is_some());
499 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")); assert_eq!(cache.used_bytes(), 10);
528 cache.insert(page([2u8; 32], 1, b"x")); 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 let mut cache = PageCache::new(2);
539 let a = [10u8; 32];
540 cache.insert(page(a, 1, b"A"));
541 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 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 {
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 let backing = dir.path().join(format!("{}.{}", hex_key(&hash), 5));
568 assert!(backing.exists(), "cache file should be spilled");
569
570 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 let mut cache = PageCache::new(6); let mut next = 1u8;
594 for _ in 0..200 {
595 let key = [next; 32];
596 cache.insert(page(key, 1, b"aa")); for _ in 0..4 {
600 let _ = cache.get(&key, Snapshot::at(Epoch(1)));
601 }
602 next = next.wrapping_add(1);
603 }
604 assert!(
607 cache.used_bytes() <= cache.capacity_bytes + 2, "used_bytes {} leaked past capacity {}",
609 cache.used_bytes(),
610 cache.capacity_bytes
611 );
612 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 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 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}