use papaya::HashMap;
use std::borrow::Borrow;
use std::hash::Hash;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
pub struct Entry<V> {
pub value: V,
pub inserted_at: Instant,
pub size_bytes: usize,
}
impl<V> Entry<V> {
pub fn new(value: V, size_bytes: usize) -> Self {
Self {
value,
inserted_at: Instant::now(),
size_bytes,
}
}
pub fn weigh(value_size: usize, key_len: usize) -> usize {
value_size + key_len + std::mem::size_of::<Self>()
}
}
pub struct EvictionStats {
pub evicted: usize,
pub freed: usize,
}
pub struct SizeBoundedMap<K, V> {
map: HashMap<K, Entry<V>>,
current_size: AtomicUsize,
max_size: usize,
}
impl<K, V> SizeBoundedMap<K, V>
where
K: Hash + Eq + Clone,
{
pub fn new(max_size_bytes: usize) -> Self {
Self {
map: HashMap::new(),
current_size: AtomicUsize::new(0),
max_size: max_size_bytes,
}
}
pub fn is_disabled(&self) -> bool {
self.max_size == 0
}
pub fn max_size(&self) -> usize {
self.max_size
}
pub fn current_size(&self) -> usize {
self.current_size.load(Ordering::Relaxed)
}
pub fn len(&self) -> usize {
self.map.pin().len()
}
pub fn is_empty(&self) -> bool {
self.map.pin().is_empty()
}
pub fn with_entry<Q, R>(&self, key: &Q, f: impl FnOnce(&Entry<V>) -> R) -> Option<R>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized,
{
if self.is_disabled() {
return None;
}
self.map.pin().get(key).map(f)
}
pub fn insert_weighted(&self, key: K, value: V, size_bytes: usize) -> (Option<V>, usize)
where
V: Clone,
{
self.insert_weighted_at(key, value, size_bytes, Instant::now())
}
pub fn insert_weighted_at(
&self,
key: K,
value: V,
size_bytes: usize,
inserted_at: Instant,
) -> (Option<V>, usize)
where
V: Clone,
{
if self.is_disabled() {
return (None, 0);
}
let entry = Entry {
value,
inserted_at,
size_bytes,
};
let guard = self.map.pin();
let (replaced_value, replaced_size) = guard
.insert(key, entry)
.map_or((None, 0), |old| (Some(old.value.clone()), old.size_bytes));
self.current_size
.fetch_sub(replaced_size, Ordering::Relaxed);
let new_total = self.current_size.fetch_add(size_bytes, Ordering::Relaxed) + size_bytes;
(replaced_value, new_total)
}
pub fn evict_until_freed<P: Ord>(
&self,
target_bytes: usize,
priority: impl Fn(&K, &Entry<V>) -> Option<P>,
) -> EvictionStats {
let guard = self.map.pin();
let mut candidates: Vec<(K, P, usize)> = guard
.iter()
.filter_map(|(k, e)| priority(k, e).map(|p| (k.clone(), p, e.size_bytes)))
.collect();
candidates.sort_by(|(_, a, _), (_, b, _)| a.cmp(b));
let mut freed = 0usize;
let mut evicted = 0usize;
for (key, _, size) in candidates {
if freed >= target_bytes {
break;
}
if guard.remove(&key).is_some() {
freed += size;
evicted += 1;
self.current_size.fetch_sub(size, Ordering::Relaxed);
}
}
EvictionStats { evicted, freed }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_insert_and_with_entry_roundtrip() {
let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(1024);
let (replaced, total) = map.insert_weighted("k".to_string(), "value".to_string(), 10);
assert!(replaced.is_none());
assert_eq!(total, 10);
let value = map.with_entry("k", |e| e.value.clone());
assert_eq!(value.as_deref(), Some("value"));
assert_eq!(map.current_size(), 10);
assert_eq!(map.len(), 1);
assert!(!map.is_empty());
}
#[test]
fn test_disabled_map_misses_and_ignores_inserts() {
let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(0);
assert!(map.is_disabled());
let (replaced, total) = map.insert_weighted("k".to_string(), "v".to_string(), 10);
assert!(replaced.is_none());
assert_eq!(total, 0);
assert!(map.with_entry("k", |e| e.value.clone()).is_none());
assert!(map.is_empty());
assert_eq!(map.current_size(), 0);
}
#[test]
fn test_overwrite_subtracts_replaced_size() {
let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(1024);
map.insert_weighted("k".to_string(), "small".to_string(), 100);
assert_eq!(map.current_size(), 100);
let (replaced, total) = map.insert_weighted("k".to_string(), "large".to_string(), 250);
assert_eq!(replaced.as_deref(), Some("small"));
assert_eq!(total, 250);
assert_eq!(map.current_size(), 250);
assert_eq!(map.len(), 1);
}
#[test]
fn test_evict_until_freed_oldest_first() {
let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
let base = Instant::now();
map.insert_weighted_at("old".to_string(), 1, 100, base);
map.insert_weighted_at(
"new".to_string(),
2,
100,
base + std::time::Duration::from_secs(1),
);
let stats = map.evict_until_freed(100, |_, e| Some(e.inserted_at));
assert_eq!(stats.evicted, 1);
assert_eq!(stats.freed, 100);
assert!(map.with_entry("old", |_| ()).is_none());
assert!(map.with_entry("new", |_| ()).is_some());
assert_eq!(map.current_size(), 100);
}
#[test]
fn test_evict_skips_entries_without_priority() {
let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
map.insert_weighted("keep".to_string(), 0, 100);
map.insert_weighted("evictable".to_string(), 1, 100);
let stats = map.evict_until_freed(1000, |_, e| (e.value > 0).then_some(e.inserted_at));
assert_eq!(stats.evicted, 1);
assert_eq!(stats.freed, 100);
assert!(map.with_entry("keep", |_| ()).is_some());
assert!(map.with_entry("evictable", |_| ()).is_none());
}
#[test]
fn test_entry_weigh_includes_key_and_overhead() {
let size = Entry::<Vec<u8>>::weigh(100, 7);
assert_eq!(size, 100 + 7 + std::mem::size_of::<Entry<Vec<u8>>>());
}
}