Skip to main content

seer_core/
cache.rs

1//! TTL-based caching with stale-while-revalidate semantics.
2//!
3//! This module provides a thread-safe cache with time-to-live (TTL) expiration
4//! and the ability to serve stale data during refresh failures.
5//!
6//! # Clock
7//!
8//! The cache uses [`tokio::time::Instant`] as its monotonic clock. In normal
9//! operation this is identical to [`std::time::Instant`]; when a test runs
10//! inside a `#[tokio::test(start_paused = true)]` runtime, the clock becomes
11//! virtual and can be advanced deterministically via `tokio::time::advance`,
12//! which keeps TTL unit tests fast and non-flaky.
13
14use 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/// A cache entry with TTL tracking.
23#[derive(Debug, Clone)]
24struct CacheEntry<V> {
25    value: V,
26    inserted_at: Instant,
27    ttl: Duration,
28}
29
30impl<V> CacheEntry<V> {
31    /// Creates a new cache entry.
32    fn new(value: V, ttl: Duration) -> Self {
33        Self {
34            value,
35            inserted_at: Instant::now(),
36            ttl,
37        }
38    }
39
40    /// Returns true if the entry has expired.
41    fn is_expired(&self) -> bool {
42        self.inserted_at.elapsed() > self.ttl
43    }
44
45    /// Returns true if the entry is stale (past 75% of TTL).
46    /// This is used for stale-while-revalidate logic.
47    fn is_stale(&self) -> bool {
48        self.inserted_at.elapsed() > (self.ttl * 3 / 4)
49    }
50
51    /// Returns the age of the entry.
52    fn age(&self) -> Duration {
53        self.inserted_at.elapsed()
54    }
55}
56
57/// Thread-safe TTL cache with stale-while-revalidate semantics.
58///
59/// This cache supports:
60/// - Automatic expiration based on TTL
61/// - Serving stale data when fresh data is unavailable
62/// - Thread-safe access via RwLock
63///
64/// # Example
65///
66/// ```
67/// use std::time::Duration;
68/// use seer_core::cache::TtlCache;
69///
70/// let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
71///
72/// // Insert a value
73/// cache.insert("key".to_string(), "value".to_string());
74///
75/// // Get the value (returns None if expired)
76/// if let Some(value) = cache.get(&"key".to_string()) {
77///     println!("Got: {}", value);
78/// }
79/// ```
80pub struct TtlCache<K, V> {
81    entries: RwLock<HashMap<K, CacheEntry<V>>>,
82    default_ttl: Duration,
83    /// Maximum number of entries. When exceeded, expired entries are purged
84    /// and if still over capacity, the oldest entry is evicted.
85    max_capacity: usize,
86}
87
88/// Default maximum capacity for TtlCache instances.
89const 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    /// Creates a new cache with the specified default TTL and default max capacity (1024).
97    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    /// Creates a new cache with a specified TTL and max capacity.
106    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    /// Gets a value from the cache if it exists and is not expired.
115    ///
116    /// Returns `None` if the key doesn't exist, the entry has expired,
117    /// or the lock is poisoned (with a warning logged).
118    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    /// Gets a value from the cache even if it's expired.
143    ///
144    /// This is useful for stale-while-revalidate patterns where you want
145    /// to serve stale data while attempting to refresh.
146    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    /// Checks if a key exists and needs refresh (is stale but not expired).
167    ///
168    /// Returns `true` if the entry exists and is past 75% of its TTL.
169    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    /// Inserts a value into the cache with the default TTL.
182    pub fn insert(&self, key: K, value: V) {
183        self.insert_with_ttl(key, value, self.default_ttl);
184    }
185
186    /// Inserts a value into the cache with a custom TTL.
187    ///
188    /// If the cache exceeds max capacity, expired entries are purged first.
189    /// If still over capacity, the oldest entry is evicted.
190    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        // Evict if at capacity (before inserting)
200        if entries.len() >= self.max_capacity && !entries.contains_key(&key) {
201            // First, remove expired entries
202            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 still at capacity, evict the oldest entries in a single batch
210            // down to a low-water mark (~90% of capacity). Doing this in one
211            // pass amortizes the O(n) selection across the next inserts instead
212            // of re-running it on every insert once full — which serialized all
213            // writers behind an O(n) scan and became a write-lock contention
214            // cliff at large capacities (issue #51). Exact-LRU is preserved:
215            // the genuinely oldest entries are the ones removed.
216            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                    // Partition the `take` oldest (largest age) to the front; an
224                    // O(n) average partial-select, no full sort.
225                    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    /// Removes a value from the cache.
239    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    /// Removes all expired entries from the cache.
251    ///
252    /// This is useful for periodic cleanup to prevent unbounded memory growth.
253    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    /// Returns the number of entries in the cache (including expired ones).
270    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    /// Returns true if the cache is empty.
281    pub fn is_empty(&self) -> bool {
282        self.len() == 0
283    }
284
285    /// Clears all entries from the cache.
286    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
298/// A single-value cache with TTL, useful for caching expensive one-off computations
299/// like bootstrap data.
300///
301/// Provides stale-while-revalidate semantics: if refresh fails, stale data can be used.
302pub struct SingleValueCache<V> {
303    entry: RwLock<Option<CacheEntry<V>>>,
304    ttl: Duration,
305}
306
307impl<V: Clone> SingleValueCache<V> {
308    /// Creates a new single-value cache with the specified TTL.
309    pub fn new(ttl: Duration) -> Self {
310        Self {
311            entry: RwLock::new(None),
312            ttl,
313        }
314    }
315
316    /// Gets the cached value if it exists and is not expired.
317    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    /// Gets the cached value even if expired (for fallback during refresh failures).
335    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    /// Checks if the cache needs refresh (value is stale or missing).
347    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    /// Checks if the cache has any value (even if expired).
363    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    /// Sets the cached value.
375    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    /// Clears the cached value.
387    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        // Wait for expiration
427        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        // Wait for expiration
439        std::thread::sleep(Duration::from_millis(20));
440
441        // get() returns None for expired
442        assert_eq!(cache.get(&"key".to_string()), None);
443        // get_stale() still returns the value
444        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        // Wait for expiration
469        std::thread::sleep(Duration::from_millis(20));
470
471        // Add a fresh entry
472        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        // Only the fresh entry should remain
483        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        // Eviction removes a BATCH of the oldest entries down to a low-water
490        // mark in a single pass, so the O(n) selection is amortized across the
491        // next inserts rather than re-run on every insert once full (issue #51).
492        // After crossing capacity the size drops below it, not hovering at it.
493        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            // 0..=100 => 101 distinct keys, crossing capacity once.
497            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        // Exact-LRU preserved: newest survives, oldest evicted.
509        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        // Wait for expiration
559        std::thread::sleep(Duration::from_millis(20));
560
561        assert!(cache.get().is_none());
562        // Stale value still available
563        assert_eq!(cache.get_stale(), Some("value".to_string()));
564    }
565
566    #[tokio::test(start_paused = true)]
567    async fn test_needs_refresh() {
568        // Uses tokio's virtual clock (TtlCache now uses tokio::time::Instant),
569        // so we advance the clock deterministically instead of real sleeps.
570        // TTL=1s, staleness threshold = 75% of TTL = 750ms.
571        let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(1));
572        cache.insert("key".to_string(), "value".to_string());
573
574        // Initially not stale.
575        assert!(!cache.needs_refresh(&"key".to_string()));
576
577        // Advance past 75% of TTL but before expiry.
578        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        // Advance past expiry.
589        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}