Skip to main content

seer_core/
cache.rs

1//! TTL-based caching.
2//!
3//! This module provides a thread-safe, capacity-bounded cache with
4//! time-to-live (TTL) expiration.
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, RwLockReadGuard, RwLockWriteGuard};
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 the age of the entry.
46    fn age(&self) -> Duration {
47        self.inserted_at.elapsed()
48    }
49}
50
51/// Thread-safe TTL cache.
52///
53/// This cache supports:
54/// - Automatic expiration based on TTL
55/// - A capacity bound (expired, then oldest, entries are evicted)
56/// - Thread-safe access via RwLock
57///
58/// # Example
59///
60/// ```
61/// use std::time::Duration;
62/// use seer_core::cache::TtlCache;
63///
64/// let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
65///
66/// // Insert a value
67/// cache.insert("key".to_string(), "value".to_string());
68///
69/// // Get the value (returns None if expired)
70/// if let Some(value) = cache.get(&"key".to_string()) {
71///     println!("Got: {}", value);
72/// }
73/// ```
74pub struct TtlCache<K, V> {
75    entries: RwLock<HashMap<K, CacheEntry<V>>>,
76    default_ttl: Duration,
77    /// Maximum number of entries. When exceeded, expired entries are purged
78    /// and if still over capacity, the oldest entry is evicted.
79    max_capacity: usize,
80}
81
82/// Default maximum capacity for TtlCache instances.
83const 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    /// Creates a new cache with the specified default TTL and default max capacity (1024).
91    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    /// Creates a new cache with a specified TTL and max capacity.
100    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    /// Read access; a poisoned lock is recovered with a warning.
109    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    /// Write access; a poisoned lock is recovered with a warning.
117    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    /// Gets a value from the cache if it exists and is not expired.
125    ///
126    /// Returns `None` if the key doesn't exist or the entry has expired.
127    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    /// Inserts a value into the cache with the default TTL.
146    pub fn insert(&self, key: K, value: V) {
147        self.insert_with_ttl(key, value, self.default_ttl);
148    }
149
150    /// Inserts a value into the cache with a custom TTL.
151    ///
152    /// If the cache exceeds max capacity, expired entries are purged first.
153    /// If still over capacity, the oldest entry is evicted.
154    pub fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) {
155        let mut entries = self.write();
156
157        // Evict if at capacity (before inserting)
158        if entries.len() >= self.max_capacity && !entries.contains_key(&key) {
159            // First, remove expired entries
160            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 still at capacity, evict the oldest entries in a single batch
168            // down to a low-water mark (~90% of capacity). Doing this in one
169            // pass amortizes the O(n) selection across the next inserts instead
170            // of re-running it on every insert once full — which serialized all
171            // writers behind an O(n) scan and became a write-lock contention
172            // cliff at large capacities (issue #51). Exact-LRU is preserved:
173            // the genuinely oldest entries are the ones removed.
174            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                    // Partition the `take` oldest (largest age) to the front; an
182                    // O(n) average partial-select, no full sort.
183                    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    /// Removes a value from the cache.
197    pub fn remove(&self, key: &K) -> Option<V> {
198        self.write().remove(key).map(|e| e.value)
199    }
200
201    /// Returns the number of entries in the cache (including expired ones).
202    pub fn len(&self) -> usize {
203        self.read().len()
204    }
205
206    /// Returns true if the cache is empty.
207    pub fn is_empty(&self) -> bool {
208        self.len() == 0
209    }
210
211    /// Clears all entries from the cache.
212    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        // Wait for expiration
245        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        // Eviction removes a BATCH of the oldest entries down to a low-water
264        // mark in a single pass, so the O(n) selection is amortized across the
265        // next inserts rather than re-run on every insert once full (issue #51).
266        // After crossing capacity the size drops below it, not hovering at it.
267        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            // 0..=100 => 101 distinct keys, crossing capacity once.
271            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        // Exact-LRU preserved: newest survives, oldest evicted.
283        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        // Uses tokio's virtual clock (TtlCache uses tokio::time::Instant), so
315        // the clock is advanced deterministically instead of real sleeps.
316        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}