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}
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 pub fn try_get(&self, key: &[u8; 32]) -> Option<std::sync::Arc<crate::columnar::NativeColumn>> {
383 self.map.get(key).cloned()
384 }
385
386 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 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 assert!(cache.get(&hash, Snapshot::at(Epoch(2))).is_none());
452 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 assert!(cache.get(&hash, Snapshot::at(Epoch(1))).is_some());
468 assert!(cache.try_get(&hash, Snapshot::at(Epoch(1))).is_some());
469 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")); assert_eq!(cache.used_bytes(), 10);
498 cache.insert(page([2u8; 32], 1, b"x")); 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 let mut cache = PageCache::new(2);
509 let a = [10u8; 32];
510 cache.insert(page(a, 1, b"A"));
511 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 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 {
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 let backing = dir.path().join(format!("{}.{}", hex_key(&hash), 5));
538 assert!(backing.exists(), "cache file should be spilled");
539
540 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 let mut cache = PageCache::new(6); let mut next = 1u8;
564 for _ in 0..200 {
565 let key = [next; 32];
566 cache.insert(page(key, 1, b"aa")); for _ in 0..4 {
570 let _ = cache.get(&key, Snapshot::at(Epoch(1)));
571 }
572 next = next.wrapping_add(1);
573 }
574 assert!(
577 cache.used_bytes() <= cache.capacity_bytes + 2, "used_bytes {} leaked past capacity {}",
579 cache.used_bytes(),
580 cache.capacity_bytes
581 );
582 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}