use std::collections::{BTreeMap, HashMap};
use std::hash::Hash;
use crate::resources::{ByteBudget, ByteReservation, ResourceError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CacheStats {
pub entries: u64,
pub bytes: u64,
pub hits: u64,
pub misses: u64,
pub evictions: u64,
}
struct Entry<V> {
value: V,
bytes: usize,
seq: u64,
}
pub struct LogicalCache<K, V> {
budget: ByteBudget,
reserved: ByteReservation,
entries: HashMap<K, Entry<V>>,
order: BTreeMap<u64, K>,
seq: u64,
hits: u64,
misses: u64,
evictions: u64,
}
impl<K, V> LogicalCache<K, V>
where
K: Eq + Hash + Clone,
{
pub fn new(budget: ByteBudget) -> Result<Self, ResourceError> {
let reserved = budget.try_reserve(0)?;
Ok(Self {
budget,
reserved,
entries: HashMap::new(),
order: BTreeMap::new(),
seq: 0,
hits: 0,
misses: 0,
evictions: 0,
})
}
fn next_seq(&mut self) -> u64 {
self.seq = self.seq.wrapping_add(1);
self.seq
}
#[must_use]
pub fn limit(&self) -> usize {
self.budget.snapshot().limit
}
pub fn get(&mut self, key: &K) -> Option<&V> {
let seq = self.next_seq();
let Some(entry) = self.entries.get_mut(key) else {
self.misses = self.misses.saturating_add(1);
return None;
};
self.hits = self.hits.saturating_add(1);
self.order.remove(&entry.seq);
entry.seq = seq;
self.order.insert(seq, key.clone());
self.entries.get(key).map(|entry| &entry.value)
}
pub fn insert(&mut self, key: K, value: V, bytes: usize) -> bool {
if bytes > self.limit() {
return false;
}
if let Some(old) = self.entries.remove(&key) {
self.order.remove(&old.seq);
let _ = self
.reserved
.resize(self.reserved.bytes().saturating_sub(old.bytes));
}
while self.reserved.bytes().saturating_add(bytes) > self.limit() {
if !self.evict_one() {
return false;
}
}
if self
.reserved
.resize(self.reserved.bytes().saturating_add(bytes))
.is_err()
{
return false;
}
let seq = self.next_seq();
self.order.insert(seq, key.clone());
self.entries.insert(key, Entry { value, bytes, seq });
true
}
fn evict_one(&mut self) -> bool {
let Some((&seq, key)) = self.order.iter().next() else {
return false;
};
let key = key.clone();
self.order.remove(&seq);
if let Some(entry) = self.entries.remove(&key) {
let _ = self
.reserved
.resize(self.reserved.bytes().saturating_sub(entry.bytes));
self.evictions = self.evictions.saturating_add(1);
return true;
}
false
}
pub fn retain(&mut self, mut keep: impl FnMut(&K, &V) -> bool) -> usize {
let doomed: Vec<K> = self
.entries
.iter()
.filter(|(key, entry)| !keep(key, &entry.value))
.map(|(key, _)| key.clone())
.collect();
for key in &doomed {
if let Some(entry) = self.entries.remove(key) {
self.order.remove(&entry.seq);
let _ = self
.reserved
.resize(self.reserved.bytes().saturating_sub(entry.bytes));
self.evictions = self.evictions.saturating_add(1);
}
}
doomed.len()
}
pub fn clear(&mut self) {
self.entries.clear();
self.order.clear();
let _ = self.reserved.resize(0);
}
#[must_use]
pub fn stats(&self) -> CacheStats {
CacheStats {
entries: self.entries.len() as u64,
bytes: self.reserved.bytes() as u64,
hits: self.hits,
misses: self.misses,
evictions: self.evictions,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cache(limit: usize) -> LogicalCache<u64, String> {
LogicalCache::new(ByteBudget::new("test-cache", limit).unwrap()).unwrap()
}
#[test]
fn evicts_least_recently_used_when_over_budget() {
let mut c = cache(100);
assert!(c.insert(1, "a".into(), 40));
assert!(c.insert(2, "b".into(), 40));
assert_eq!(c.get(&1).map(String::as_str), Some("a"));
assert!(c.insert(3, "c".into(), 40)); assert!(c.get(&2).is_none(), "LRU evicted");
assert_eq!(c.get(&1).map(String::as_str), Some("a"));
assert_eq!(c.get(&3).map(String::as_str), Some("c"));
let s = c.stats();
assert_eq!(s.entries, 2);
assert_eq!(s.bytes, 80);
assert_eq!(s.evictions, 1);
assert_eq!(s.hits, 3); assert_eq!(s.misses, 1);
}
#[test]
fn oversized_entry_is_not_cached_and_releases_nothing() {
let mut c = cache(100);
assert!(c.insert(1, "a".into(), 40));
assert!(!c.insert(2, "huge".into(), 500), "oversized rejected");
assert!(c.get(&2).is_none());
assert_eq!(c.stats().bytes, 40, "failed insert charges nothing");
assert_eq!(c.get(&1).map(String::as_str), Some("a"));
}
#[test]
fn replacing_releases_the_old_charge() {
let mut c = cache(100);
c.insert(1, "a".into(), 60);
c.insert(1, "bb".into(), 20);
let s = c.stats();
assert_eq!(s.entries, 1);
assert_eq!(s.bytes, 20);
assert_eq!(c.get(&1).map(String::as_str), Some("bb"));
}
#[test]
fn budget_too_small_to_hold_any_entry_caches_nothing() {
let mut c = cache(10);
assert!(!c.insert(1, "a".into(), 20));
assert!(c.get(&1).is_none());
assert_eq!(c.stats().bytes, 0);
}
#[test]
fn clear_releases_all_bytes() {
let mut c = cache(100);
c.insert(1, "a".into(), 40);
c.insert(2, "b".into(), 40);
c.clear();
assert_eq!(c.stats().entries, 0);
assert_eq!(c.stats().bytes, 0);
}
}