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