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
6use std::collections::HashMap;
7use std::hash::Hash;
8use std::sync::RwLock;
9use std::time::{Duration, Instant};
10
11use tracing::{debug, warn};
12
13/// A cache entry with TTL tracking.
14#[derive(Debug, Clone)]
15struct CacheEntry<V> {
16    value: V,
17    inserted_at: Instant,
18    ttl: Duration,
19}
20
21impl<V> CacheEntry<V> {
22    /// Creates a new cache entry.
23    fn new(value: V, ttl: Duration) -> Self {
24        Self {
25            value,
26            inserted_at: Instant::now(),
27            ttl,
28        }
29    }
30
31    /// Returns true if the entry has expired.
32    fn is_expired(&self) -> bool {
33        self.inserted_at.elapsed() > self.ttl
34    }
35
36    /// Returns true if the entry is stale (past 75% of TTL).
37    /// This is used for stale-while-revalidate logic.
38    fn is_stale(&self) -> bool {
39        self.inserted_at.elapsed() > (self.ttl * 3 / 4)
40    }
41
42    /// Returns the age of the entry.
43    fn age(&self) -> Duration {
44        self.inserted_at.elapsed()
45    }
46}
47
48/// Thread-safe TTL cache with stale-while-revalidate semantics.
49///
50/// This cache supports:
51/// - Automatic expiration based on TTL
52/// - Serving stale data when fresh data is unavailable
53/// - Thread-safe access via RwLock
54///
55/// # Example
56///
57/// ```
58/// use std::time::Duration;
59/// use seer_core::cache::TtlCache;
60///
61/// let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
62///
63/// // Insert a value
64/// cache.insert("key".to_string(), "value".to_string());
65///
66/// // Get the value (returns None if expired)
67/// if let Some(value) = cache.get(&"key".to_string()) {
68///     println!("Got: {}", value);
69/// }
70/// ```
71pub struct TtlCache<K, V> {
72    entries: RwLock<HashMap<K, CacheEntry<V>>>,
73    default_ttl: Duration,
74    /// Maximum number of entries. When exceeded, expired entries are purged
75    /// and if still over capacity, the oldest entry is evicted.
76    max_capacity: usize,
77}
78
79/// Default maximum capacity for TtlCache instances.
80const DEFAULT_MAX_CAPACITY: usize = 1024;
81
82impl<K, V> TtlCache<K, V>
83where
84    K: Eq + Hash + Clone + std::fmt::Debug,
85    V: Clone,
86{
87    /// Creates a new cache with the specified default TTL and default max capacity (1024).
88    pub fn new(default_ttl: Duration) -> Self {
89        Self {
90            entries: RwLock::new(HashMap::new()),
91            default_ttl,
92            max_capacity: DEFAULT_MAX_CAPACITY,
93        }
94    }
95
96    /// Creates a new cache with a specified TTL and max capacity.
97    pub fn with_max_capacity(default_ttl: Duration, max_capacity: usize) -> Self {
98        Self {
99            entries: RwLock::new(HashMap::new()),
100            default_ttl,
101            max_capacity,
102        }
103    }
104
105    /// Gets a value from the cache if it exists and is not expired.
106    ///
107    /// Returns `None` if the key doesn't exist, the entry has expired,
108    /// or the lock is poisoned (with a warning logged).
109    pub fn get(&self, key: &K) -> Option<V> {
110        let entries = match self.entries.read() {
111            Ok(guard) => guard,
112            Err(poisoned) => {
113                warn!("Cache read lock poisoned, recovering");
114                poisoned.into_inner()
115            }
116        };
117        let entry = entries.get(key)?;
118
119        if entry.is_expired() {
120            debug!(
121                ?key,
122                age_secs = entry.age().as_secs(),
123                "Cache entry expired"
124            );
125            None
126        } else {
127            Some(entry.value.clone())
128        }
129    }
130
131    /// Gets a value from the cache even if it's expired.
132    ///
133    /// This is useful for stale-while-revalidate patterns where you want
134    /// to serve stale data while attempting to refresh.
135    pub fn get_stale(&self, key: &K) -> Option<V> {
136        let entries = match self.entries.read() {
137            Ok(guard) => guard,
138            Err(poisoned) => {
139                warn!("Cache read lock poisoned, recovering");
140                poisoned.into_inner()
141            }
142        };
143        entries.get(key).map(|entry| {
144            if entry.is_expired() {
145                debug!(
146                    ?key,
147                    age_secs = entry.age().as_secs(),
148                    "Serving stale cache entry"
149                );
150            }
151            entry.value.clone()
152        })
153    }
154
155    /// Checks if a key exists and needs refresh (is stale but not expired).
156    ///
157    /// Returns `true` if the entry exists and is past 75% of its TTL.
158    pub fn needs_refresh(&self, key: &K) -> bool {
159        let entries = match self.entries.read() {
160            Ok(guard) => guard,
161            Err(poisoned) => {
162                warn!("Cache read lock poisoned, recovering");
163                poisoned.into_inner()
164            }
165        };
166
167        entries.get(key).is_some_and(|entry| entry.is_stale())
168    }
169
170    /// Inserts a value into the cache with the default TTL.
171    pub fn insert(&self, key: K, value: V) {
172        self.insert_with_ttl(key, value, self.default_ttl);
173    }
174
175    /// Inserts a value into the cache with a custom TTL.
176    ///
177    /// If the cache exceeds max capacity, expired entries are purged first.
178    /// If still over capacity, the oldest entry is evicted.
179    pub fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) {
180        let mut entries = match self.entries.write() {
181            Ok(guard) => guard,
182            Err(poisoned) => {
183                warn!("Cache write lock poisoned, recovering");
184                poisoned.into_inner()
185            }
186        };
187
188        // Evict if at capacity (before inserting)
189        if entries.len() >= self.max_capacity && !entries.contains_key(&key) {
190            // First, remove expired entries
191            let before = entries.len();
192            entries.retain(|_, entry| !entry.is_expired());
193            let removed = before - entries.len();
194            if removed > 0 {
195                debug!(removed, "Evicted expired entries to make room");
196            }
197
198            // If still at capacity, evict the oldest entry
199            if entries.len() >= self.max_capacity {
200                if let Some(oldest_key) = entries
201                    .iter()
202                    .max_by_key(|(_, entry)| entry.age())
203                    .map(|(k, _)| k.clone())
204                {
205                    entries.remove(&oldest_key);
206                    debug!(?oldest_key, "Evicted oldest entry to make room");
207                }
208            }
209        }
210
211        debug!(?key, ttl_secs = ttl.as_secs(), "Inserting cache entry");
212        entries.insert(key, CacheEntry::new(value, ttl));
213    }
214
215    /// Removes a value from the cache.
216    pub fn remove(&self, key: &K) -> Option<V> {
217        let mut entries = match self.entries.write() {
218            Ok(guard) => guard,
219            Err(poisoned) => {
220                warn!("Cache write lock poisoned, recovering");
221                poisoned.into_inner()
222            }
223        };
224        entries.remove(key).map(|e| e.value)
225    }
226
227    /// Removes all expired entries from the cache.
228    ///
229    /// This is useful for periodic cleanup to prevent unbounded memory growth.
230    pub fn cleanup(&self) {
231        let mut entries = match self.entries.write() {
232            Ok(guard) => guard,
233            Err(poisoned) => {
234                warn!("Cache write lock poisoned, recovering");
235                poisoned.into_inner()
236            }
237        };
238        let before = entries.len();
239        entries.retain(|_, entry| !entry.is_expired());
240        let removed = before - entries.len();
241        if removed > 0 {
242            debug!(removed, remaining = entries.len(), "Cache cleanup complete");
243        }
244    }
245
246    /// Returns the number of entries in the cache (including expired ones).
247    pub fn len(&self) -> usize {
248        match self.entries.read() {
249            Ok(entries) => entries.len(),
250            Err(poisoned) => {
251                warn!("Cache read lock poisoned, recovering");
252                poisoned.into_inner().len()
253            }
254        }
255    }
256
257    /// Returns true if the cache is empty.
258    pub fn is_empty(&self) -> bool {
259        self.len() == 0
260    }
261
262    /// Clears all entries from the cache.
263    pub fn clear(&self) {
264        let mut entries = match self.entries.write() {
265            Ok(guard) => guard,
266            Err(poisoned) => {
267                warn!("Cache write lock poisoned, recovering");
268                poisoned.into_inner()
269            }
270        };
271        entries.clear();
272    }
273}
274
275/// A single-value cache with TTL, useful for caching expensive one-off computations
276/// like bootstrap data.
277///
278/// Provides stale-while-revalidate semantics: if refresh fails, stale data can be used.
279pub struct SingleValueCache<V> {
280    entry: RwLock<Option<CacheEntry<V>>>,
281    ttl: Duration,
282}
283
284impl<V: Clone> SingleValueCache<V> {
285    /// Creates a new single-value cache with the specified TTL.
286    pub fn new(ttl: Duration) -> Self {
287        Self {
288            entry: RwLock::new(None),
289            ttl,
290        }
291    }
292
293    /// Gets the cached value if it exists and is not expired.
294    pub fn get(&self) -> Option<V> {
295        let guard = match self.entry.read() {
296            Ok(guard) => guard,
297            Err(poisoned) => {
298                warn!("SingleValueCache read lock poisoned, recovering");
299                poisoned.into_inner()
300            }
301        };
302        let entry = guard.as_ref()?;
303
304        if entry.is_expired() {
305            None
306        } else {
307            Some(entry.value.clone())
308        }
309    }
310
311    /// Gets the cached value even if expired (for fallback during refresh failures).
312    pub fn get_stale(&self) -> Option<V> {
313        let guard = match self.entry.read() {
314            Ok(guard) => guard,
315            Err(poisoned) => {
316                warn!("SingleValueCache read lock poisoned, recovering");
317                poisoned.into_inner()
318            }
319        };
320        guard.as_ref().map(|e| e.value.clone())
321    }
322
323    /// Checks if the cache needs refresh (value is stale or missing).
324    pub fn needs_refresh(&self) -> bool {
325        let guard = match self.entry.read() {
326            Ok(guard) => guard,
327            Err(poisoned) => {
328                warn!("SingleValueCache read lock poisoned, recovering");
329                poisoned.into_inner()
330            }
331        };
332
333        match guard.as_ref() {
334            Some(e) => e.is_stale(),
335            None => true,
336        }
337    }
338
339    /// Checks if the cache has any value (even if expired).
340    pub fn has_value(&self) -> bool {
341        let guard = match self.entry.read() {
342            Ok(guard) => guard,
343            Err(poisoned) => {
344                warn!("SingleValueCache read lock poisoned, recovering");
345                poisoned.into_inner()
346            }
347        };
348        guard.is_some()
349    }
350
351    /// Sets the cached value.
352    pub fn set(&self, value: V) {
353        let mut guard = match self.entry.write() {
354            Ok(guard) => guard,
355            Err(poisoned) => {
356                warn!("SingleValueCache write lock poisoned, recovering");
357                poisoned.into_inner()
358            }
359        };
360        *guard = Some(CacheEntry::new(value, self.ttl));
361    }
362
363    /// Clears the cached value.
364    pub fn clear(&self) {
365        let mut guard = match self.entry.write() {
366            Ok(guard) => guard,
367            Err(poisoned) => {
368                warn!("SingleValueCache write lock poisoned, recovering");
369                poisoned.into_inner()
370            }
371        };
372        *guard = None;
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_cache_insert_and_get() {
382        let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
383
384        cache.insert("key".to_string(), "value".to_string());
385
386        assert_eq!(cache.get(&"key".to_string()), Some("value".to_string()));
387    }
388
389    #[test]
390    fn test_cache_get_missing_key() {
391        let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
392
393        assert_eq!(cache.get(&"missing".to_string()), None);
394    }
395
396    #[test]
397    fn test_cache_expiration() {
398        let cache: TtlCache<String, String> = TtlCache::new(Duration::from_millis(10));
399
400        cache.insert("key".to_string(), "value".to_string());
401        assert_eq!(cache.get(&"key".to_string()), Some("value".to_string()));
402
403        // Wait for expiration
404        std::thread::sleep(Duration::from_millis(20));
405
406        assert_eq!(cache.get(&"key".to_string()), None);
407    }
408
409    #[test]
410    fn test_cache_get_stale_after_expiration() {
411        let cache: TtlCache<String, String> = TtlCache::new(Duration::from_millis(10));
412
413        cache.insert("key".to_string(), "value".to_string());
414
415        // Wait for expiration
416        std::thread::sleep(Duration::from_millis(20));
417
418        // get() returns None for expired
419        assert_eq!(cache.get(&"key".to_string()), None);
420        // get_stale() still returns the value
421        assert_eq!(
422            cache.get_stale(&"key".to_string()),
423            Some("value".to_string())
424        );
425    }
426
427    #[test]
428    fn test_cache_remove() {
429        let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
430
431        cache.insert("key".to_string(), "value".to_string());
432        assert!(cache.get(&"key".to_string()).is_some());
433
434        cache.remove(&"key".to_string());
435        assert!(cache.get(&"key".to_string()).is_none());
436    }
437
438    #[test]
439    fn test_cache_cleanup() {
440        let cache: TtlCache<String, String> = TtlCache::new(Duration::from_millis(10));
441
442        cache.insert("key1".to_string(), "value1".to_string());
443        cache.insert("key2".to_string(), "value2".to_string());
444
445        // Wait for expiration
446        std::thread::sleep(Duration::from_millis(20));
447
448        // Add a fresh entry
449        cache.insert_with_ttl(
450            "key3".to_string(),
451            "value3".to_string(),
452            Duration::from_secs(3600),
453        );
454
455        assert_eq!(cache.len(), 3);
456
457        cache.cleanup();
458
459        // Only the fresh entry should remain
460        assert_eq!(cache.len(), 1);
461        assert_eq!(cache.get(&"key3".to_string()), Some("value3".to_string()));
462    }
463
464    #[test]
465    fn test_cache_clear() {
466        let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
467
468        cache.insert("key1".to_string(), "value1".to_string());
469        cache.insert("key2".to_string(), "value2".to_string());
470
471        assert_eq!(cache.len(), 2);
472
473        cache.clear();
474
475        assert_eq!(cache.len(), 0);
476        assert!(cache.is_empty());
477    }
478
479    #[test]
480    fn test_single_value_cache() {
481        let cache: SingleValueCache<String> = SingleValueCache::new(Duration::from_secs(3600));
482
483        assert!(!cache.has_value());
484        assert!(cache.get().is_none());
485
486        cache.set("value".to_string());
487
488        assert!(cache.has_value());
489        assert_eq!(cache.get(), Some("value".to_string()));
490    }
491
492    #[test]
493    fn test_single_value_cache_expiration() {
494        let cache: SingleValueCache<String> = SingleValueCache::new(Duration::from_millis(10));
495
496        cache.set("value".to_string());
497        assert_eq!(cache.get(), Some("value".to_string()));
498
499        // Wait for expiration
500        std::thread::sleep(Duration::from_millis(20));
501
502        assert!(cache.get().is_none());
503        // Stale value still available
504        assert_eq!(cache.get_stale(), Some("value".to_string()));
505    }
506
507    #[test]
508    fn test_needs_refresh() {
509        // TTL of 1000ms, staleness at 750ms
510        // Use large TTL to avoid flaky timing on slow CI runners
511        let cache: TtlCache<String, String> = TtlCache::new(Duration::from_millis(1000));
512
513        cache.insert("key".to_string(), "value".to_string());
514
515        // Initially not stale
516        assert!(!cache.needs_refresh(&"key".to_string()));
517
518        // Wait until stale (past 75% of TTL = 750ms)
519        std::thread::sleep(Duration::from_millis(800));
520
521        // Now should be stale
522        assert!(cache.needs_refresh(&"key".to_string()));
523
524        // But still valid (not expired) — 200ms of headroom
525        assert!(cache.get(&"key".to_string()).is_some());
526    }
527}