use std::time::{Duration, Instant};
use dashmap::DashMap;
pub(crate) const CACHE_EVICTION_PERCENTAGE: usize = 10;
pub(crate) fn evict_expired_then_oldest<K, V>(
map: &DashMap<K, V>,
max_entries: usize,
fetched_at: impl Fn(&V) -> Instant,
ttl: impl Fn(&V) -> Duration,
) where
K: Eq + std::hash::Hash + Clone,
{
if map.len() < max_entries {
return;
}
let now = Instant::now();
map.retain(|_, v| now.duration_since(fetched_at(v)) < ttl(v));
if map.len() >= max_entries
&& let Some(oldest) = map
.iter()
.min_by_key(|e| fetched_at(e.value()))
.map(|e| e.key().clone())
{
map.remove(&oldest);
}
}
pub(crate) fn evict_oldest_batch<K, V>(
map: &DashMap<K, V>,
max_entries: usize,
fetched_at: impl Fn(&V) -> Instant,
) where
K: Eq + std::hash::Hash + Clone + Ord,
{
use std::cmp::Reverse;
use std::collections::BinaryHeap;
let target_removals = (max_entries / CACHE_EVICTION_PERCENTAGE).max(1);
let mut oldest: BinaryHeap<Reverse<(Instant, K)>> = map
.iter()
.map(|entry| Reverse((fetched_at(entry.value()), entry.key().clone())))
.collect();
for _ in 0..target_removals {
let Some(Reverse((_, key))) = oldest.pop() else {
break;
};
map.remove(&key);
}
}
#[cfg(test)]
mod tests {
use super::{CACHE_EVICTION_PERCENTAGE, evict_oldest_batch};
use dashmap::DashMap;
use std::time::{Duration, Instant};
struct Entry {
fetched_at: Instant,
}
fn map_with_ages(count: usize) -> DashMap<usize, Entry> {
let map = DashMap::new();
let base = Instant::now();
for i in 0..count {
map.insert(
i,
Entry {
fetched_at: base + Duration::from_secs(i as u64),
},
);
}
map
}
#[test]
fn removes_exactly_the_target_count() {
let map = map_with_ages(100);
evict_oldest_batch(&map, 100, |e| e.fetched_at);
assert_eq!(map.len(), 90);
}
#[test]
fn removes_oldest_entries_and_keeps_newest() {
let map = map_with_ages(100);
let target_removals = (100 / CACHE_EVICTION_PERCENTAGE).max(1);
evict_oldest_batch(&map, 100, |e| e.fetched_at);
for key in 0..target_removals {
assert!(!map.contains_key(&key), "expected key {key} to be evicted");
}
for key in target_removals..100 {
assert!(map.contains_key(&key), "expected key {key} to survive");
}
}
#[test]
fn floors_target_removals_at_one_below_percentage_threshold() {
let map = map_with_ages(5);
evict_oldest_batch(&map, 5, |e| e.fetched_at);
assert_eq!(map.len(), 5 - 1);
assert!(!map.contains_key(&0));
}
#[test]
fn empties_without_panicking_when_smaller_than_target() {
let map = map_with_ages(3);
evict_oldest_batch(&map, 1000, |e| e.fetched_at);
assert!(map.is_empty());
}
#[test]
fn no_op_on_empty_map() {
let map: DashMap<usize, Entry> = DashMap::new();
evict_oldest_batch(&map, 1000, |e| e.fetched_at);
assert!(map.is_empty());
}
#[test]
fn removes_one_when_max_entries_equals_percentage() {
let map = map_with_ages(CACHE_EVICTION_PERCENTAGE);
evict_oldest_batch(&map, CACHE_EVICTION_PERCENTAGE, |e| e.fetched_at);
assert_eq!(map.len(), CACHE_EVICTION_PERCENTAGE - 1);
assert!(!map.contains_key(&0));
}
#[test]
fn ties_on_fetched_at_break_by_key_order() {
let map: DashMap<String, Entry> = DashMap::new();
let same_instant = Instant::now();
map.insert(
"b".to_string(),
Entry {
fetched_at: same_instant,
},
);
map.insert(
"a".to_string(),
Entry {
fetched_at: same_instant,
},
);
evict_oldest_batch(&map, CACHE_EVICTION_PERCENTAGE, |e| e.fetched_at);
assert!(
!map.contains_key("a"),
"the lexicographically smaller key breaks the tie"
);
assert!(map.contains_key("b"));
}
}