use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::cache::{CacheKey, ResponseCache};
use crate::transport::RawResponse;
pub const DEFAULT_TTL: Duration = Duration::from_secs(300);
pub const DEFAULT_CAPACITY: usize = 1024;
#[derive(Debug, Clone)]
struct Entry {
response: RawResponse,
stored_at: Instant,
touched: u64,
}
#[derive(Debug, Default)]
struct Store {
entries: HashMap<CacheKey, Entry>,
clock: u64,
}
#[derive(Debug)]
pub struct MemoryCache {
store: Mutex<Store>,
ttl: Duration,
capacity: usize,
}
impl MemoryCache {
pub fn new() -> Self {
MemoryCache::with_ttl(DEFAULT_TTL)
}
pub fn with_ttl(ttl: Duration) -> Self {
MemoryCache {
store: Mutex::new(Store::default()),
ttl,
capacity: DEFAULT_CAPACITY,
}
}
pub fn with_ttl_and_capacity(ttl: Duration, capacity: usize) -> Self {
MemoryCache {
store: Mutex::new(Store::default()),
ttl,
capacity: capacity.max(1),
}
}
pub fn ttl(&self) -> Duration {
self.ttl
}
pub fn capacity(&self) -> usize {
self.capacity
}
pub fn purge_expired(&self) {
let mut store = self.lock();
let ttl = self.ttl;
store
.entries
.retain(|_, entry| entry.stored_at.elapsed() < ttl);
}
fn lock(&self) -> std::sync::MutexGuard<'_, Store> {
self.store
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn evict_one(store: &mut Store) {
let victim = store
.entries
.iter()
.min_by_key(|(_, entry)| entry.touched)
.map(|(key, _)| key.clone());
if let Some(key) = victim {
store.entries.remove(&key);
}
}
}
impl Default for MemoryCache {
fn default() -> Self {
MemoryCache::new()
}
}
impl ResponseCache for MemoryCache {
fn get(&self, key: &CacheKey) -> Option<RawResponse> {
let mut store = self.lock();
store.clock += 1;
let now = store.clock;
let entry = store.entries.get_mut(key)?;
if entry.stored_at.elapsed() >= self.ttl {
store.entries.remove(key);
return None;
}
entry.touched = now;
Some(entry.response.clone())
}
fn put(&self, key: CacheKey, response: RawResponse) {
let mut store = self.lock();
store.clock += 1;
let touched = store.clock;
if store.entries.len() >= self.capacity && !store.entries.contains_key(&key) {
let ttl = self.ttl;
store
.entries
.retain(|_, entry| entry.stored_at.elapsed() < ttl);
while store.entries.len() >= self.capacity {
MemoryCache::evict_one(&mut store);
}
}
store.entries.insert(
key,
Entry {
response,
stored_at: Instant::now(),
touched,
},
);
}
fn clear(&self) {
self.lock().entries.clear();
}
fn len(&self) -> Option<usize> {
let store = self.lock();
let ttl = self.ttl;
Some(
store
.entries
.values()
.filter(|entry| entry.stored_at.elapsed() < ttl)
.count(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::Endpoint;
use crate::transport::ResponseKind;
fn response(text: &str) -> RawResponse {
RawResponse::new(
Endpoint::whois("whois.example"),
ResponseKind::WhoisText,
text,
Duration::from_millis(1),
)
}
fn key(name: &str) -> CacheKey {
CacheKey::new("whois.example", name)
}
#[test]
fn stores_and_returns() {
let cache = MemoryCache::new();
cache.put(key("a.com"), response("record a"));
assert_eq!(cache.get(&key("a.com")).unwrap().text(), "record a");
assert!(cache.get(&key("b.com")).is_none());
assert_eq!(cache.len(), Some(1));
}
#[test]
fn expired_entries_are_not_returned() {
let cache = MemoryCache::with_ttl(Duration::ZERO);
cache.put(key("a.com"), response("record a"));
assert!(cache.get(&key("a.com")).is_none());
assert_eq!(cache.len(), Some(0));
}
#[test]
fn a_live_entry_survives_a_long_ttl() {
let cache = MemoryCache::with_ttl(Duration::from_secs(3600));
cache.put(key("a.com"), response("record a"));
assert!(cache.get(&key("a.com")).is_some());
}
#[test]
fn clear_empties_the_cache() {
let cache = MemoryCache::new();
cache.put(key("a.com"), response("a"));
cache.put(key("b.com"), response("b"));
cache.clear();
assert_eq!(cache.len(), Some(0));
assert!(cache.get(&key("a.com")).is_none());
}
#[test]
fn the_capacity_is_respected() {
let cache = MemoryCache::with_ttl_and_capacity(Duration::from_secs(3600), 2);
cache.put(key("a.com"), response("a"));
cache.put(key("b.com"), response("b"));
cache.put(key("c.com"), response("c"));
assert_eq!(cache.len(), Some(2));
}
#[test]
fn eviction_takes_the_least_recently_used() {
let cache = MemoryCache::with_ttl_and_capacity(Duration::from_secs(3600), 2);
cache.put(key("a.com"), response("a"));
cache.put(key("b.com"), response("b"));
assert!(cache.get(&key("a.com")).is_some());
cache.put(key("c.com"), response("c"));
assert!(
cache.get(&key("a.com")).is_some(),
"recently used entry was evicted"
);
assert!(cache.get(&key("c.com")).is_some());
assert!(cache.get(&key("b.com")).is_none(), "coldest entry survived");
}
#[test]
fn overwriting_a_key_does_not_evict() {
let cache = MemoryCache::with_ttl_and_capacity(Duration::from_secs(3600), 2);
cache.put(key("a.com"), response("first"));
cache.put(key("b.com"), response("b"));
cache.put(key("a.com"), response("second"));
assert_eq!(cache.len(), Some(2));
assert_eq!(cache.get(&key("a.com")).unwrap().text(), "second");
assert!(cache.get(&key("b.com")).is_some());
}
#[test]
fn purge_expired_reclaims_only_the_dead() {
let cache = MemoryCache::with_ttl(Duration::ZERO);
cache.put(key("a.com"), response("a"));
cache.purge_expired();
assert_eq!(cache.len(), Some(0));
}
#[test]
fn a_zero_capacity_is_clamped_to_one() {
let cache = MemoryCache::with_ttl_and_capacity(Duration::from_secs(60), 0);
assert_eq!(cache.capacity(), 1);
cache.put(key("a.com"), response("a"));
assert_eq!(cache.len(), Some(1));
}
#[test]
fn is_usable_from_several_threads() {
let cache = std::sync::Arc::new(MemoryCache::new());
let handles: Vec<_> = (0..8)
.map(|index| {
let cache = std::sync::Arc::clone(&cache);
std::thread::spawn(move || {
let name = format!("{index}.com");
cache.put(key(&name), response(&name));
cache.get(&key(&name))
})
})
.collect();
for handle in handles {
assert!(handle.join().unwrap().is_some());
}
assert_eq!(cache.len(), Some(8));
}
}