1use std::collections::HashMap;
15use std::hash::Hash;
16use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
17use std::time::Duration;
18
19use tokio::time::Instant;
20use tracing::{debug, warn};
21
22#[derive(Debug, Clone)]
24struct CacheEntry<V> {
25 value: V,
26 inserted_at: Instant,
27 ttl: Duration,
28}
29
30impl<V> CacheEntry<V> {
31 fn new(value: V, ttl: Duration) -> Self {
33 Self {
34 value,
35 inserted_at: Instant::now(),
36 ttl,
37 }
38 }
39
40 fn is_expired(&self) -> bool {
42 self.inserted_at.elapsed() > self.ttl
43 }
44
45 fn age(&self) -> Duration {
47 self.inserted_at.elapsed()
48 }
49}
50
51pub struct TtlCache<K, V> {
75 entries: RwLock<HashMap<K, CacheEntry<V>>>,
76 default_ttl: Duration,
77 max_capacity: usize,
80}
81
82const DEFAULT_MAX_CAPACITY: usize = 1024;
84
85impl<K, V> TtlCache<K, V>
86where
87 K: Eq + Hash + Clone + std::fmt::Debug,
88 V: Clone,
89{
90 pub fn new(default_ttl: Duration) -> Self {
92 Self {
93 entries: RwLock::new(HashMap::new()),
94 default_ttl,
95 max_capacity: DEFAULT_MAX_CAPACITY,
96 }
97 }
98
99 pub fn with_max_capacity(default_ttl: Duration, max_capacity: usize) -> Self {
101 Self {
102 entries: RwLock::new(HashMap::new()),
103 default_ttl,
104 max_capacity,
105 }
106 }
107
108 fn read(&self) -> RwLockReadGuard<'_, HashMap<K, CacheEntry<V>>> {
110 self.entries.read().unwrap_or_else(|poisoned| {
111 warn!("Cache read lock poisoned, recovering");
112 poisoned.into_inner()
113 })
114 }
115
116 fn write(&self) -> RwLockWriteGuard<'_, HashMap<K, CacheEntry<V>>> {
118 self.entries.write().unwrap_or_else(|poisoned| {
119 warn!("Cache write lock poisoned, recovering");
120 poisoned.into_inner()
121 })
122 }
123
124 pub fn get(&self, key: &K) -> Option<V> {
128 let entries = self.read();
129 let entry = entries.get(key)?;
130
131 if entry.is_expired() {
132 debug!(
133 hit = false,
134 ?key,
135 age_secs = entry.age().as_secs(),
136 "cache lookup (expired)"
137 );
138 None
139 } else {
140 debug!(hit = true, ?key, "cache lookup");
141 Some(entry.value.clone())
142 }
143 }
144
145 pub fn insert(&self, key: K, value: V) {
147 self.insert_with_ttl(key, value, self.default_ttl);
148 }
149
150 pub fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) {
155 let mut entries = self.write();
156
157 if entries.len() >= self.max_capacity && !entries.contains_key(&key) {
159 let before = entries.len();
161 entries.retain(|_, entry| !entry.is_expired());
162 let removed = before - entries.len();
163 if removed > 0 {
164 debug!(removed, "Evicted expired entries to make room");
165 }
166
167 if entries.len() >= self.max_capacity {
175 let low_water = self.max_capacity.saturating_mul(9) / 10;
176 let to_remove = entries.len().saturating_sub(low_water).max(1);
177 let mut aged: Vec<(K, Duration)> =
178 entries.iter().map(|(k, e)| (k.clone(), e.age())).collect();
179 let take = to_remove.min(aged.len());
180 if take < aged.len() {
181 aged.select_nth_unstable_by(take - 1, |a, b| b.1.cmp(&a.1));
184 }
185 for (k, _) in aged.into_iter().take(take) {
186 entries.remove(&k);
187 }
188 debug!(evicted = take, "Batch-evicted oldest entries to make room");
189 }
190 }
191
192 debug!(?key, ttl_secs = ttl.as_secs(), "Inserting cache entry");
193 entries.insert(key, CacheEntry::new(value, ttl));
194 }
195
196 pub fn remove(&self, key: &K) -> Option<V> {
198 self.write().remove(key).map(|e| e.value)
199 }
200
201 pub fn len(&self) -> usize {
203 self.read().len()
204 }
205
206 pub fn is_empty(&self) -> bool {
208 self.len() == 0
209 }
210
211 pub fn clear(&self) {
213 self.write().clear();
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_cache_insert_and_get() {
223 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
224
225 cache.insert("key".to_string(), "value".to_string());
226
227 assert_eq!(cache.get(&"key".to_string()), Some("value".to_string()));
228 }
229
230 #[test]
231 fn test_cache_get_missing_key() {
232 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
233
234 assert_eq!(cache.get(&"missing".to_string()), None);
235 }
236
237 #[test]
238 fn test_cache_expiration() {
239 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_millis(10));
240
241 cache.insert("key".to_string(), "value".to_string());
242 assert_eq!(cache.get(&"key".to_string()), Some("value".to_string()));
243
244 std::thread::sleep(Duration::from_millis(20));
246
247 assert_eq!(cache.get(&"key".to_string()), None);
248 }
249
250 #[test]
251 fn test_cache_remove() {
252 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
253
254 cache.insert("key".to_string(), "value".to_string());
255 assert!(cache.get(&"key".to_string()).is_some());
256
257 cache.remove(&"key".to_string());
258 assert!(cache.get(&"key".to_string()).is_none());
259 }
260
261 #[test]
262 fn capacity_eviction_batches_to_low_water_mark() {
263 let cap = 100;
268 let cache: TtlCache<u32, u32> = TtlCache::with_max_capacity(Duration::from_secs(3600), cap);
269 for i in 0..=cap as u32 {
270 cache.insert(i, i);
272 }
273 assert!(
274 cache.len() <= (cap * 9 / 10) + 1,
275 "batch eviction should drop to ~90% low-water, got len {}",
276 cache.len()
277 );
278 assert!(
279 cache.len() < cap,
280 "must be below capacity after a batch evict"
281 );
282 assert_eq!(cache.get(&(cap as u32)), Some(cap as u32));
284 assert_eq!(cache.get(&0), None);
285 }
286
287 #[test]
288 fn capacity_eviction_never_exceeds_capacity_under_churn() {
289 let cap = 50;
290 let cache: TtlCache<u32, u32> = TtlCache::with_max_capacity(Duration::from_secs(3600), cap);
291 for i in 0..1000u32 {
292 cache.insert(i, i);
293 assert!(cache.len() <= cap, "len {} exceeded cap {cap}", cache.len());
294 }
295 }
296
297 #[test]
298 fn test_cache_clear() {
299 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
300
301 cache.insert("key1".to_string(), "value1".to_string());
302 cache.insert("key2".to_string(), "value2".to_string());
303
304 assert_eq!(cache.len(), 2);
305
306 cache.clear();
307
308 assert_eq!(cache.len(), 0);
309 assert!(cache.is_empty());
310 }
311
312 #[tokio::test(start_paused = true)]
313 async fn test_entry_expires_after_ttl() {
314 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(1));
317 cache.insert("key".to_string(), "value".to_string());
318
319 tokio::time::advance(Duration::from_millis(800)).await;
320 assert!(
321 cache.get(&"key".to_string()).is_some(),
322 "entry must not be expired at t=800ms (< 1000ms TTL)"
323 );
324
325 tokio::time::advance(Duration::from_millis(300)).await;
326 assert!(
327 cache.get(&"key".to_string()).is_none(),
328 "entry must be expired at t=1100ms (> 1000ms TTL)"
329 );
330 }
331}