Skip to main content

agentic_evolve_core/cache/
lru.rs

1//! Generic LRU cache with time-to-live expiration.
2
3use std::collections::HashMap;
4use std::hash::Hash;
5use std::sync::RwLock;
6use std::time::{Duration, Instant};
7
8use serde::{Deserialize, Serialize};
9
10use super::metrics::CacheMetrics;
11
12/// An entry in the LRU cache, tracking value, insertion time, and access order.
13struct CacheEntry<V> {
14    value: V,
15    inserted_at: Instant,
16    last_accessed: Instant,
17}
18
19/// A generic LRU cache with configurable max size and TTL.
20///
21/// Thread-safe via internal `RwLock`. Expired entries are lazily evicted on access.
22pub struct LruCache<K, V> {
23    entries: RwLock<HashMap<K, CacheEntry<V>>>,
24    max_size: usize,
25    ttl: Duration,
26    metrics: CacheMetrics,
27}
28
29/// Serializable configuration for constructing an `LruCache`.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct LruCacheConfig {
32    pub max_size: usize,
33    pub ttl_secs: u64,
34}
35
36impl Default for LruCacheConfig {
37    fn default() -> Self {
38        Self {
39            max_size: 1024,
40            ttl_secs: 300,
41        }
42    }
43}
44
45impl<K, V> LruCache<K, V>
46where
47    K: Eq + Hash + Clone,
48    V: Clone,
49{
50    /// Create a new LRU cache with the given capacity and TTL.
51    pub fn new(max_size: usize, ttl: Duration) -> Self {
52        Self {
53            entries: RwLock::new(HashMap::new()),
54            max_size,
55            ttl,
56            metrics: CacheMetrics::new(),
57        }
58    }
59
60    /// Create from a serializable config.
61    pub fn from_config(config: &LruCacheConfig) -> Self {
62        Self::new(config.max_size, Duration::from_secs(config.ttl_secs))
63    }
64
65    /// Get a value by key. Returns `None` if missing or expired.
66    pub fn get(&self, key: &K) -> Option<V> {
67        let mut map = self.entries.write().unwrap();
68        match map.get_mut(key) {
69            Some(entry) if entry.inserted_at.elapsed() < self.ttl => {
70                entry.last_accessed = Instant::now();
71                self.metrics.record_hit();
72                Some(entry.value.clone())
73            }
74            Some(_) => {
75                // Expired — remove it.
76                map.remove(key);
77                self.metrics.record_eviction();
78                self.metrics.record_miss();
79                self.metrics.set_size(map.len());
80                None
81            }
82            None => {
83                self.metrics.record_miss();
84                None
85            }
86        }
87    }
88
89    /// Insert a key-value pair. Evicts the LRU entry if at capacity.
90    pub fn insert(&self, key: K, value: V) {
91        let mut map = self.entries.write().unwrap();
92
93        // Evict expired entries first.
94        self.evict_expired(&mut map);
95
96        // If still at capacity, evict the least-recently-accessed entry.
97        if map.len() >= self.max_size && !map.contains_key(&key) {
98            self.evict_lru(&mut map);
99        }
100
101        let now = Instant::now();
102        map.insert(
103            key,
104            CacheEntry {
105                value,
106                inserted_at: now,
107                last_accessed: now,
108            },
109        );
110        self.metrics.set_size(map.len());
111    }
112
113    /// Invalidate (remove) a specific key.
114    pub fn invalidate(&self, key: &K) -> bool {
115        let mut map = self.entries.write().unwrap();
116        let removed = map.remove(key).is_some();
117        if removed {
118            self.metrics.record_eviction();
119        }
120        self.metrics.set_size(map.len());
121        removed
122    }
123
124    /// Clear the entire cache.
125    pub fn clear(&self) {
126        let mut map = self.entries.write().unwrap();
127        let count = map.len();
128        map.clear();
129        for _ in 0..count {
130            self.metrics.record_eviction();
131        }
132        self.metrics.set_size(0);
133    }
134
135    /// Check if a key exists and is not expired.
136    pub fn contains(&self, key: &K) -> bool {
137        let map = self.entries.read().unwrap();
138        match map.get(key) {
139            Some(entry) => entry.inserted_at.elapsed() < self.ttl,
140            None => false,
141        }
142    }
143
144    /// Return the number of entries (including possibly-expired ones).
145    pub fn len(&self) -> usize {
146        self.entries.read().unwrap().len()
147    }
148
149    /// Check if the cache is empty.
150    pub fn is_empty(&self) -> bool {
151        self.len() == 0
152    }
153
154    /// Access the cache metrics.
155    pub fn metrics(&self) -> &CacheMetrics {
156        &self.metrics
157    }
158
159    // -- internal helpers --
160
161    fn evict_expired(&self, map: &mut HashMap<K, CacheEntry<V>>) {
162        let expired: Vec<K> = map
163            .iter()
164            .filter(|(_, e)| e.inserted_at.elapsed() >= self.ttl)
165            .map(|(k, _)| k.clone())
166            .collect();
167        for k in expired {
168            map.remove(&k);
169            self.metrics.record_eviction();
170        }
171    }
172
173    fn evict_lru(&self, map: &mut HashMap<K, CacheEntry<V>>) {
174        if let Some(lru_key) = map
175            .iter()
176            .min_by_key(|(_, e)| e.last_accessed)
177            .map(|(k, _)| k.clone())
178        {
179            map.remove(&lru_key);
180            self.metrics.record_eviction();
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use std::thread;
189
190    #[test]
191    fn insert_and_get() {
192        let cache = LruCache::new(10, Duration::from_secs(60));
193        cache.insert("a", 1);
194        assert_eq!(cache.get(&"a"), Some(1));
195    }
196
197    #[test]
198    fn missing_key_returns_none() {
199        let cache: LruCache<&str, i32> = LruCache::new(10, Duration::from_secs(60));
200        assert_eq!(cache.get(&"missing"), None);
201    }
202
203    #[test]
204    fn ttl_expiration() {
205        let cache = LruCache::new(10, Duration::from_millis(50));
206        cache.insert("x", 42);
207        assert_eq!(cache.get(&"x"), Some(42));
208        thread::sleep(Duration::from_millis(60));
209        assert_eq!(cache.get(&"x"), None);
210    }
211
212    #[test]
213    fn evict_lru_on_full() {
214        let cache = LruCache::new(2, Duration::from_secs(60));
215        cache.insert("a", 1);
216        cache.insert("b", 2);
217        // Access "a" to make "b" the LRU.
218        let _ = cache.get(&"a");
219        cache.insert("c", 3);
220        // "b" should have been evicted.
221        assert_eq!(cache.get(&"b"), None);
222        assert_eq!(cache.get(&"a"), Some(1));
223        assert_eq!(cache.get(&"c"), Some(3));
224    }
225
226    #[test]
227    fn invalidate_removes_key() {
228        let cache = LruCache::new(10, Duration::from_secs(60));
229        cache.insert("k", 99);
230        assert!(cache.invalidate(&"k"));
231        assert_eq!(cache.get(&"k"), None);
232    }
233
234    #[test]
235    fn clear_empties_cache() {
236        let cache = LruCache::new(10, Duration::from_secs(60));
237        cache.insert("a", 1);
238        cache.insert("b", 2);
239        cache.clear();
240        assert!(cache.is_empty());
241    }
242
243    #[test]
244    fn contains_respects_ttl() {
245        let cache = LruCache::new(10, Duration::from_millis(50));
246        cache.insert("k", 1);
247        assert!(cache.contains(&"k"));
248        thread::sleep(Duration::from_millis(60));
249        assert!(!cache.contains(&"k"));
250    }
251
252    #[test]
253    fn metrics_track_hits_and_misses() {
254        let cache = LruCache::new(10, Duration::from_secs(60));
255        cache.insert("a", 1);
256        let _ = cache.get(&"a"); // hit
257        let _ = cache.get(&"b"); // miss
258        assert_eq!(cache.metrics().hit_count(), 1);
259        assert_eq!(cache.metrics().miss_count(), 1);
260    }
261
262    #[test]
263    fn from_config_works() {
264        let config = LruCacheConfig {
265            max_size: 5,
266            ttl_secs: 120,
267        };
268        let cache: LruCache<String, String> = LruCache::from_config(&config);
269        assert_eq!(cache.max_size, 5);
270    }
271
272    #[test]
273    fn len_tracks_insertions() {
274        let cache = LruCache::new(10, Duration::from_secs(60));
275        assert_eq!(cache.len(), 0);
276        cache.insert("a", 1);
277        assert_eq!(cache.len(), 1);
278        cache.insert("b", 2);
279        assert_eq!(cache.len(), 2);
280    }
281}