use std::time::{Duration, Instant};
use dashmap::DashMap;
pub const CACHE_EVICTION_PERCENTAGE: usize = 10;
pub 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 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);
}
}
pub fn evict_arbitrary_if_full<K, V>(map: &DashMap<K, V>, max_entries: usize)
where
K: Eq + std::hash::Hash + Clone,
{
if map.len() < max_entries {
return;
}
let victim = map.iter().next().map(|e| e.key().clone());
if let Some(key) = victim {
map.remove(&key);
}
}
pub fn evict_expired_then_clear_all<K, V>(
map: &DashMap<K, V>,
max_entries: usize,
fetched_at: impl Fn(&V) -> Instant,
ttl: Duration,
) where
K: Eq + std::hash::Hash,
{
let now = Instant::now();
map.retain(|_, v| now.saturating_duration_since(fetched_at(v)) < ttl);
if map.len() >= max_entries {
map.clear();
}
}
#[cfg(test)]
mod tests {
use super::{
CACHE_EVICTION_PERCENTAGE, evict_arbitrary_if_full, evict_expired_then_clear_all,
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"));
}
#[test]
fn evict_arbitrary_if_full_removes_exactly_one_at_capacity() {
let map: DashMap<u32, &str> = DashMap::new();
map.insert(1, "a");
map.insert(2, "b");
evict_arbitrary_if_full(&map, 2);
assert_eq!(map.len(), 1);
}
#[test]
fn evict_arbitrary_if_full_below_capacity_is_a_no_op() {
let map: DashMap<u32, &str> = DashMap::new();
map.insert(1, "a");
evict_arbitrary_if_full(&map, 2);
assert_eq!(map.len(), 1);
}
#[test]
fn evict_arbitrary_if_full_empty_map_is_a_no_op() {
let map: DashMap<u32, &str> = DashMap::new();
evict_arbitrary_if_full(&map, 2);
assert!(map.is_empty());
}
#[test]
fn evict_expired_then_clear_all_drops_only_expired_entries() {
let map: DashMap<&str, Instant> = DashMap::new();
map.insert(
"expired",
Instant::now().checked_sub(Duration::from_secs(61)).unwrap(),
);
map.insert("fresh", Instant::now());
evict_expired_then_clear_all(&map, 256, |v| *v, Duration::from_secs(60));
assert!(!map.contains_key("expired"));
assert!(map.contains_key("fresh"));
}
#[test]
fn evict_expired_then_clear_all_clears_all_when_still_at_capacity_after_ttl_sweep() {
let map: DashMap<usize, Instant> = DashMap::new();
let max_entries = 8;
for i in 0..max_entries {
map.insert(i, Instant::now());
}
evict_expired_then_clear_all(&map, max_entries, |v| *v, Duration::from_secs(60));
assert!(map.is_empty());
}
}