1use std::collections::HashMap;
15use std::hash::Hash;
16use std::sync::RwLock;
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 is_stale(&self) -> bool {
48 self.inserted_at.elapsed() > (self.ttl * 3 / 4)
49 }
50
51 fn age(&self) -> Duration {
53 self.inserted_at.elapsed()
54 }
55}
56
57pub struct TtlCache<K, V> {
81 entries: RwLock<HashMap<K, CacheEntry<V>>>,
82 default_ttl: Duration,
83 max_capacity: usize,
86}
87
88const DEFAULT_MAX_CAPACITY: usize = 1024;
90
91impl<K, V> TtlCache<K, V>
92where
93 K: Eq + Hash + Clone + std::fmt::Debug,
94 V: Clone,
95{
96 pub fn new(default_ttl: Duration) -> Self {
98 Self {
99 entries: RwLock::new(HashMap::new()),
100 default_ttl,
101 max_capacity: DEFAULT_MAX_CAPACITY,
102 }
103 }
104
105 pub fn with_max_capacity(default_ttl: Duration, max_capacity: usize) -> Self {
107 Self {
108 entries: RwLock::new(HashMap::new()),
109 default_ttl,
110 max_capacity,
111 }
112 }
113
114 pub fn get(&self, key: &K) -> Option<V> {
119 let entries = match self.entries.read() {
120 Ok(guard) => guard,
121 Err(poisoned) => {
122 warn!("Cache read lock poisoned, recovering");
123 poisoned.into_inner()
124 }
125 };
126 let entry = entries.get(key)?;
127
128 if entry.is_expired() {
129 debug!(
130 hit = false,
131 ?key,
132 age_secs = entry.age().as_secs(),
133 "cache lookup (expired)"
134 );
135 None
136 } else {
137 debug!(hit = true, ?key, "cache lookup");
138 Some(entry.value.clone())
139 }
140 }
141
142 pub fn get_stale(&self, key: &K) -> Option<V> {
147 let entries = match self.entries.read() {
148 Ok(guard) => guard,
149 Err(poisoned) => {
150 warn!("Cache read lock poisoned, recovering");
151 poisoned.into_inner()
152 }
153 };
154 entries.get(key).map(|entry| {
155 if entry.is_expired() {
156 debug!(
157 ?key,
158 age_secs = entry.age().as_secs(),
159 "Serving stale cache entry"
160 );
161 }
162 entry.value.clone()
163 })
164 }
165
166 pub fn needs_refresh(&self, key: &K) -> bool {
170 let entries = match self.entries.read() {
171 Ok(guard) => guard,
172 Err(poisoned) => {
173 warn!("Cache read lock poisoned, recovering");
174 poisoned.into_inner()
175 }
176 };
177
178 entries.get(key).is_some_and(|entry| entry.is_stale())
179 }
180
181 pub fn insert(&self, key: K, value: V) {
183 self.insert_with_ttl(key, value, self.default_ttl);
184 }
185
186 pub fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) {
191 let mut entries = match self.entries.write() {
192 Ok(guard) => guard,
193 Err(poisoned) => {
194 warn!("Cache write lock poisoned, recovering");
195 poisoned.into_inner()
196 }
197 };
198
199 if entries.len() >= self.max_capacity && !entries.contains_key(&key) {
201 let before = entries.len();
203 entries.retain(|_, entry| !entry.is_expired());
204 let removed = before - entries.len();
205 if removed > 0 {
206 debug!(removed, "Evicted expired entries to make room");
207 }
208
209 if entries.len() >= self.max_capacity {
217 let low_water = self.max_capacity.saturating_mul(9) / 10;
218 let to_remove = entries.len().saturating_sub(low_water).max(1);
219 let mut aged: Vec<(K, Duration)> =
220 entries.iter().map(|(k, e)| (k.clone(), e.age())).collect();
221 let take = to_remove.min(aged.len());
222 if take < aged.len() {
223 aged.select_nth_unstable_by(take - 1, |a, b| b.1.cmp(&a.1));
226 }
227 for (k, _) in aged.into_iter().take(take) {
228 entries.remove(&k);
229 }
230 debug!(evicted = take, "Batch-evicted oldest entries to make room");
231 }
232 }
233
234 debug!(?key, ttl_secs = ttl.as_secs(), "Inserting cache entry");
235 entries.insert(key, CacheEntry::new(value, ttl));
236 }
237
238 pub fn remove(&self, key: &K) -> Option<V> {
240 let mut entries = match self.entries.write() {
241 Ok(guard) => guard,
242 Err(poisoned) => {
243 warn!("Cache write lock poisoned, recovering");
244 poisoned.into_inner()
245 }
246 };
247 entries.remove(key).map(|e| e.value)
248 }
249
250 pub fn cleanup(&self) {
254 let mut entries = match self.entries.write() {
255 Ok(guard) => guard,
256 Err(poisoned) => {
257 warn!("Cache write lock poisoned, recovering");
258 poisoned.into_inner()
259 }
260 };
261 let before = entries.len();
262 entries.retain(|_, entry| !entry.is_expired());
263 let removed = before - entries.len();
264 if removed > 0 {
265 debug!(removed, remaining = entries.len(), "Cache cleanup complete");
266 }
267 }
268
269 pub fn len(&self) -> usize {
271 match self.entries.read() {
272 Ok(entries) => entries.len(),
273 Err(poisoned) => {
274 warn!("Cache read lock poisoned, recovering");
275 poisoned.into_inner().len()
276 }
277 }
278 }
279
280 pub fn is_empty(&self) -> bool {
282 self.len() == 0
283 }
284
285 pub fn clear(&self) {
287 let mut entries = match self.entries.write() {
288 Ok(guard) => guard,
289 Err(poisoned) => {
290 warn!("Cache write lock poisoned, recovering");
291 poisoned.into_inner()
292 }
293 };
294 entries.clear();
295 }
296}
297
298pub struct SingleValueCache<V> {
303 entry: RwLock<Option<CacheEntry<V>>>,
304 ttl: Duration,
305}
306
307impl<V: Clone> SingleValueCache<V> {
308 pub fn new(ttl: Duration) -> Self {
310 Self {
311 entry: RwLock::new(None),
312 ttl,
313 }
314 }
315
316 pub fn get(&self) -> Option<V> {
318 let guard = match self.entry.read() {
319 Ok(guard) => guard,
320 Err(poisoned) => {
321 warn!("SingleValueCache read lock poisoned, recovering");
322 poisoned.into_inner()
323 }
324 };
325 let entry = guard.as_ref()?;
326
327 if entry.is_expired() {
328 None
329 } else {
330 Some(entry.value.clone())
331 }
332 }
333
334 pub fn get_stale(&self) -> Option<V> {
336 let guard = match self.entry.read() {
337 Ok(guard) => guard,
338 Err(poisoned) => {
339 warn!("SingleValueCache read lock poisoned, recovering");
340 poisoned.into_inner()
341 }
342 };
343 guard.as_ref().map(|e| e.value.clone())
344 }
345
346 pub fn needs_refresh(&self) -> bool {
348 let guard = match self.entry.read() {
349 Ok(guard) => guard,
350 Err(poisoned) => {
351 warn!("SingleValueCache read lock poisoned, recovering");
352 poisoned.into_inner()
353 }
354 };
355
356 match guard.as_ref() {
357 Some(e) => e.is_stale(),
358 None => true,
359 }
360 }
361
362 pub fn has_value(&self) -> bool {
364 let guard = match self.entry.read() {
365 Ok(guard) => guard,
366 Err(poisoned) => {
367 warn!("SingleValueCache read lock poisoned, recovering");
368 poisoned.into_inner()
369 }
370 };
371 guard.is_some()
372 }
373
374 pub fn set(&self, value: V) {
376 let mut guard = match self.entry.write() {
377 Ok(guard) => guard,
378 Err(poisoned) => {
379 warn!("SingleValueCache write lock poisoned, recovering");
380 poisoned.into_inner()
381 }
382 };
383 *guard = Some(CacheEntry::new(value, self.ttl));
384 }
385
386 pub fn clear(&self) {
388 let mut guard = match self.entry.write() {
389 Ok(guard) => guard,
390 Err(poisoned) => {
391 warn!("SingleValueCache write lock poisoned, recovering");
392 poisoned.into_inner()
393 }
394 };
395 *guard = None;
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn test_cache_insert_and_get() {
405 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
406
407 cache.insert("key".to_string(), "value".to_string());
408
409 assert_eq!(cache.get(&"key".to_string()), Some("value".to_string()));
410 }
411
412 #[test]
413 fn test_cache_get_missing_key() {
414 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
415
416 assert_eq!(cache.get(&"missing".to_string()), None);
417 }
418
419 #[test]
420 fn test_cache_expiration() {
421 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_millis(10));
422
423 cache.insert("key".to_string(), "value".to_string());
424 assert_eq!(cache.get(&"key".to_string()), Some("value".to_string()));
425
426 std::thread::sleep(Duration::from_millis(20));
428
429 assert_eq!(cache.get(&"key".to_string()), None);
430 }
431
432 #[test]
433 fn test_cache_get_stale_after_expiration() {
434 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_millis(10));
435
436 cache.insert("key".to_string(), "value".to_string());
437
438 std::thread::sleep(Duration::from_millis(20));
440
441 assert_eq!(cache.get(&"key".to_string()), None);
443 assert_eq!(
445 cache.get_stale(&"key".to_string()),
446 Some("value".to_string())
447 );
448 }
449
450 #[test]
451 fn test_cache_remove() {
452 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
453
454 cache.insert("key".to_string(), "value".to_string());
455 assert!(cache.get(&"key".to_string()).is_some());
456
457 cache.remove(&"key".to_string());
458 assert!(cache.get(&"key".to_string()).is_none());
459 }
460
461 #[test]
462 fn test_cache_cleanup() {
463 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_millis(10));
464
465 cache.insert("key1".to_string(), "value1".to_string());
466 cache.insert("key2".to_string(), "value2".to_string());
467
468 std::thread::sleep(Duration::from_millis(20));
470
471 cache.insert_with_ttl(
473 "key3".to_string(),
474 "value3".to_string(),
475 Duration::from_secs(3600),
476 );
477
478 assert_eq!(cache.len(), 3);
479
480 cache.cleanup();
481
482 assert_eq!(cache.len(), 1);
484 assert_eq!(cache.get(&"key3".to_string()), Some("value3".to_string()));
485 }
486
487 #[test]
488 fn capacity_eviction_batches_to_low_water_mark() {
489 let cap = 100;
494 let cache: TtlCache<u32, u32> = TtlCache::with_max_capacity(Duration::from_secs(3600), cap);
495 for i in 0..=cap as u32 {
496 cache.insert(i, i);
498 }
499 assert!(
500 cache.len() <= (cap * 9 / 10) + 1,
501 "batch eviction should drop to ~90% low-water, got len {}",
502 cache.len()
503 );
504 assert!(
505 cache.len() < cap,
506 "must be below capacity after a batch evict"
507 );
508 assert_eq!(cache.get(&(cap as u32)), Some(cap as u32));
510 assert_eq!(cache.get(&0), None);
511 }
512
513 #[test]
514 fn capacity_eviction_never_exceeds_capacity_under_churn() {
515 let cap = 50;
516 let cache: TtlCache<u32, u32> = TtlCache::with_max_capacity(Duration::from_secs(3600), cap);
517 for i in 0..1000u32 {
518 cache.insert(i, i);
519 assert!(cache.len() <= cap, "len {} exceeded cap {cap}", cache.len());
520 }
521 }
522
523 #[test]
524 fn test_cache_clear() {
525 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
526
527 cache.insert("key1".to_string(), "value1".to_string());
528 cache.insert("key2".to_string(), "value2".to_string());
529
530 assert_eq!(cache.len(), 2);
531
532 cache.clear();
533
534 assert_eq!(cache.len(), 0);
535 assert!(cache.is_empty());
536 }
537
538 #[test]
539 fn test_single_value_cache() {
540 let cache: SingleValueCache<String> = SingleValueCache::new(Duration::from_secs(3600));
541
542 assert!(!cache.has_value());
543 assert!(cache.get().is_none());
544
545 cache.set("value".to_string());
546
547 assert!(cache.has_value());
548 assert_eq!(cache.get(), Some("value".to_string()));
549 }
550
551 #[test]
552 fn test_single_value_cache_expiration() {
553 let cache: SingleValueCache<String> = SingleValueCache::new(Duration::from_millis(10));
554
555 cache.set("value".to_string());
556 assert_eq!(cache.get(), Some("value".to_string()));
557
558 std::thread::sleep(Duration::from_millis(20));
560
561 assert!(cache.get().is_none());
562 assert_eq!(cache.get_stale(), Some("value".to_string()));
564 }
565
566 #[tokio::test(start_paused = true)]
567 async fn test_needs_refresh() {
568 let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(1));
572 cache.insert("key".to_string(), "value".to_string());
573
574 assert!(!cache.needs_refresh(&"key".to_string()));
576
577 tokio::time::advance(Duration::from_millis(800)).await;
579 assert!(
580 cache.needs_refresh(&"key".to_string()),
581 "entry must be stale at t=800ms (>= 750ms threshold)"
582 );
583 assert!(
584 cache.get(&"key".to_string()).is_some(),
585 "entry must not be expired at t=800ms (< 1000ms TTL)"
586 );
587
588 tokio::time::advance(Duration::from_millis(300)).await;
590 assert!(
591 cache.get(&"key".to_string()).is_none(),
592 "entry must be expired at t=1100ms (> 1000ms TTL)"
593 );
594 }
595}