Skip to main content

vtcode_core/tools/
lru_cache.rs

1//! LRU cache with TTL enforcement and observability hooks.
2//!
3//! Provides a production-ready cache for tool results, pattern data, and LLM responses.
4//! Includes metrics collection and optional logging.
5
6use hashbrown::{HashMap, HashSet};
7use std::collections::VecDeque;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10use tokio::sync::RwLock;
11
12// Arc brings shared ownership of cached values
13/// Cache entry with TTL tracking.
14#[derive(Debug)]
15struct CacheEntry<V> {
16    value: Arc<V>,
17    inserted_at: Instant,
18    accessed_at: Instant,
19    access_count: u64,
20}
21
22/// Combined cache state: entries and access order protected by a single lock.
23///
24/// This eliminates the deadlock risk from acquiring multiple locks simultaneously.
25#[derive(Debug)]
26struct CacheState<V> {
27    entries: HashMap<String, CacheEntry<V>>,
28    access_order: VecDeque<String>,
29}
30
31impl<V> CacheEntry<V> {
32    #[inline]
33    fn is_expired(&self, ttl: Duration) -> bool {
34        self.inserted_at.elapsed() > ttl
35    }
36
37    #[inline]
38    fn update_access(&mut self) {
39        self.accessed_at = Instant::now();
40        self.access_count += 1;
41    }
42}
43
44/// Statistics about cache performance.
45#[derive(Clone, Copy, Debug, Default)]
46pub struct CacheStats {
47    /// Total hit count across all entries.
48    pub hits: u64,
49    /// Total miss count.
50    pub misses: u64,
51    /// Total evictions due to capacity.
52    pub evictions: u64,
53    /// Total expirations.
54    pub expirations: u64,
55}
56
57impl CacheStats {
58    /// Hit rate as percentage (0.0 to 100.0).
59    #[inline]
60    pub fn hit_rate(&self) -> f64 {
61        let total = self.hits + self.misses;
62        if total == 0 {
63            0.0
64        } else {
65            (self.hits as f64 / total as f64) * 100.0
66        }
67    }
68}
69
70/// Observability hook for cache events.
71#[async_trait::async_trait]
72pub trait CacheObserver: Send + Sync {
73    async fn on_hit(&self, key: &str, access_count: u64);
74    async fn on_miss(&self, key: &str);
75    async fn on_evict(&self, key: &str, reason: EvictionReason);
76}
77
78/// Why an entry was evicted.
79#[derive(Clone, Debug, Copy, PartialEq, Eq)]
80pub enum EvictionReason {
81    Capacity,
82    Expired,
83    Manual,
84}
85
86/// Noop observer (default).
87pub struct NoopObserver;
88
89#[async_trait::async_trait]
90impl CacheObserver for NoopObserver {
91    async fn on_hit(&self, _: &str, _: u64) {}
92    async fn on_miss(&self, _: &str) {}
93    async fn on_evict(&self, _: &str, _: EvictionReason) {}
94}
95
96/// LRU cache with TTL, capacity limits, and observability.
97pub struct LruCache<V> {
98    /// Maximum entries before LRU eviction.
99    capacity: usize,
100    /// TTL for all entries.
101    ttl: Duration,
102    /// Combined cache state: entries and access order protected by a single lock.
103    state: Arc<RwLock<CacheState<V>>>,
104    /// Stats tracking.
105    stats: Arc<RwLock<CacheStats>>,
106    /// Observability hook.
107    observer: Arc<dyn CacheObserver>,
108}
109
110impl<V: Send + Sync> LruCache<V> {
111    /// Create a new cache with capacity and TTL.
112    pub fn new(capacity: usize, ttl: Duration) -> Self {
113        Self::with_observer(capacity, ttl, Arc::new(NoopObserver))
114    }
115
116    /// Create a cache with a custom observer.
117    pub fn with_observer(capacity: usize, ttl: Duration, observer: Arc<dyn CacheObserver>) -> Self {
118        Self {
119            capacity,
120            ttl,
121            state: Arc::new(RwLock::new(CacheState {
122                entries: HashMap::new(),
123                access_order: VecDeque::new(),
124            })),
125            stats: Arc::new(RwLock::new(CacheStats::default())),
126            observer,
127        }
128    }
129
130    /// Get a value from the cache.
131    pub async fn get(&self, key: &str) -> Option<Arc<V>> {
132        // Perform all state mutations under a single lock acquisition.
133        enum GetOutcome<V> {
134            Hit { value: Arc<V>, access_count: u64 },
135            Miss,
136            Expired,
137        }
138
139        let outcome = {
140            let mut state = self.state.write().await;
141
142            // Check for expiration first.
143            if let Some(entry) = state.entries.get(key) {
144                if entry.is_expired(self.ttl) {
145                    state.entries.remove(key);
146                    state.access_order.retain(|k| k != key);
147                    GetOutcome::Expired
148                } else {
149                    // Valid hit - extract value and access count, then update entry.
150                    let value = Arc::clone(&entry.value);
151                    let access_count = entry.access_count;
152
153                    // Update the entry's access metadata.
154                    if let Some(entry) = state.entries.get_mut(key) {
155                        entry.update_access();
156                    }
157
158                    // Move to back of access order (most recently used).
159                    state.access_order.retain(|k| k != key);
160                    state.access_order.push_back(key.to_string());
161
162                    GetOutcome::Hit { value, access_count }
163                }
164            } else {
165                GetOutcome::Miss
166            }
167        };
168
169        // Handle observer callbacks outside the lock.
170        match outcome {
171            GetOutcome::Hit { value, access_count } => {
172                let mut stats = self.stats.write().await;
173                stats.hits += 1;
174                self.observer.on_hit(key, access_count).await;
175                Some(value)
176            }
177            GetOutcome::Expired => {
178                let mut stats = self.stats.write().await;
179                stats.expirations += 1;
180                stats.misses += 1;
181                self.observer.on_evict(key, EvictionReason::Expired).await;
182                None
183            }
184            GetOutcome::Miss => {
185                let mut stats = self.stats.write().await;
186                stats.misses += 1;
187                self.observer.on_miss(key).await;
188                None
189            }
190        }
191    }
192
193    /// Get a value as an owned clone from the cache (compatibility helper).
194    pub async fn get_owned(&self, key: &str) -> Option<V>
195    where
196        V: Clone,
197    {
198        self.get(key).await.map(|arc| V::clone(&arc))
199    }
200
201    /// Alias to return `Arc<V>` explicitly (clarifies intent).
202    pub async fn get_arc(&self, key: &str) -> Option<Arc<V>> {
203        self.get(key).await
204    }
205
206    /// Insert a value into the cache.
207    pub async fn insert(&self, key: String, value: V) {
208        self.insert_arc(key, Arc::new(value)).await;
209    }
210
211    /// Insert an Arc-wrapped value into the cache to avoid extra cloning.
212    pub async fn insert_arc(&self, key: String, value: Arc<V>) {
213        let capacity_evicted = {
214            let mut state = self.state.write().await;
215            let mut evicted: Option<String> = None;
216
217            // If at capacity and key doesn't exist, evict LRU entry.
218            if state.entries.len() >= self.capacity && !state.entries.contains_key(&key) {
219                if let Some(lru_key) = state.access_order.pop_front() {
220                    state.entries.remove(&lru_key);
221                    evicted = Some(lru_key);
222                }
223            }
224
225            let entry = CacheEntry {
226                value: Arc::clone(&value),
227                inserted_at: Instant::now(),
228                accessed_at: Instant::now(),
229                access_count: 0,
230            };
231
232            state.entries.insert(key.clone(), entry);
233            state.access_order.retain(|existing| existing != &key);
234            state.access_order.push_back(key);
235            evicted
236        };
237
238        // Avoid awaiting external observer hooks while cache lock is held.
239        if let Some(evicted_key) = capacity_evicted {
240            self.observer.on_evict(&evicted_key, EvictionReason::Capacity).await;
241            let mut stats = self.stats.write().await;
242            stats.evictions += 1;
243        }
244    }
245
246    /// Remove a specific key.
247    pub async fn remove(&self, key: &str) -> Option<Arc<V>> {
248        let removed = {
249            let mut state = self.state.write().await;
250            state.access_order.retain(|k| k != key);
251            state.entries.remove(key).map(|e| e.value)
252        };
253
254        if removed.is_some() {
255            self.observer.on_evict(key, EvictionReason::Manual).await;
256        }
257        removed
258    }
259
260    /// Clear all entries.
261    pub async fn clear(&self) {
262        let mut state = self.state.write().await;
263        state.entries.clear();
264        state.access_order.clear();
265        let mut stats = self.stats.write().await;
266        *stats = CacheStats::default();
267    }
268
269    /// Get current cache statistics.
270    pub async fn stats(&self) -> CacheStats {
271        *self.stats.read().await
272    }
273
274    /// Get number of entries in cache.
275    pub async fn len(&self) -> usize {
276        self.state.read().await.entries.len()
277    }
278
279    /// Check if cache is empty.
280    pub async fn is_empty(&self) -> bool {
281        self.state.read().await.entries.is_empty()
282    }
283
284    /// Get all keys in cache (excluding expired).
285    pub async fn keys(&self) -> Vec<String> {
286        let state = self.state.read().await;
287        state
288            .entries
289            .iter()
290            .filter(|(_, entry)| !entry.is_expired(self.ttl))
291            .map(|(k, _)| k.clone())
292            .collect()
293    }
294
295    /// Remove expired entries.
296    pub async fn prune_expired(&self) {
297        let expired = {
298            let mut state = self.state.write().await;
299
300            let mut expired = Vec::new();
301            state.entries.retain(|key, entry| {
302                let keep = !entry.is_expired(self.ttl);
303                if !keep {
304                    expired.push(key.clone());
305                }
306                keep
307            });
308
309            if !expired.is_empty() {
310                let expired_set: HashSet<_> = expired.iter().cloned().collect();
311                state.access_order.retain(|k| !expired_set.contains(k));
312            }
313
314            expired
315        };
316
317        if expired.is_empty() {
318            return;
319        }
320
321        for key in &expired {
322            self.observer.on_evict(key, EvictionReason::Expired).await;
323        }
324
325        let mut stats = self.stats.write().await;
326        stats.expirations += expired.len() as u64;
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[tokio::test]
335    async fn test_basic_operations() {
336        let cache: LruCache<String> = LruCache::new(3, Duration::from_secs(60));
337
338        cache.insert_arc("a".into(), Arc::new("value_a".into())).await;
339        cache.insert_arc("b".into(), Arc::new("value_b".into())).await;
340
341        assert_eq!(cache.get("a").await.map(|v| (*v).clone()), Some("value_a".into()));
342        assert_eq!(cache.get("b").await.map(|v| (*v).clone()), Some("value_b".into()));
343        assert_eq!(cache.get("c").await, None);
344    }
345
346    #[tokio::test]
347    async fn test_capacity_eviction() {
348        let cache: LruCache<i32> = LruCache::new(2, Duration::from_secs(60));
349
350        cache.insert("a".into(), 1).await;
351        cache.insert("b".into(), 2).await;
352        cache.insert("c".into(), 3).await; // Should evict "a"
353
354        assert_eq!(cache.get("a").await, None);
355        assert_eq!(cache.get("b").await.map(|v| *v), Some(2));
356        assert_eq!(cache.get("c").await.map(|v| *v), Some(3));
357    }
358
359    #[tokio::test]
360    async fn test_ttl_expiration() {
361        let cache: LruCache<String> = LruCache::new(10, Duration::from_millis(50));
362
363        cache.insert_arc("a".into(), Arc::new("value".into())).await;
364        assert_eq!(cache.get("a").await.map(|v| (*v).clone()), Some("value".into()));
365
366        tokio::time::sleep(Duration::from_millis(100)).await;
367        assert_eq!(cache.get("a").await, None);
368    }
369
370    #[tokio::test]
371    async fn test_stats() {
372        let cache: LruCache<String> = LruCache::new(10, Duration::from_secs(60));
373
374        cache.insert_arc("a".into(), Arc::new("value".into())).await;
375        cache.get("a").await; // hit
376        cache.get("b").await; // miss
377
378        let stats = cache.stats().await;
379        assert_eq!(stats.hits, 1);
380        assert_eq!(stats.misses, 1);
381    }
382
383    #[tokio::test]
384    async fn test_prune_expired() {
385        let cache: LruCache<i32> = LruCache::new(10, Duration::from_millis(50));
386
387        cache.insert("a".into(), 1).await;
388        cache.insert("b".into(), 2).await;
389        tokio::time::sleep(Duration::from_millis(100)).await;
390        cache.prune_expired().await;
391
392        assert_eq!(cache.len().await, 0);
393    }
394
395    #[tokio::test]
396    async fn insert_arc_avoids_clone() {
397        let cache = LruCache::new(2, Duration::from_secs(60));
398        let v = Arc::new(42);
399        cache.insert_arc("k1".to_string(), Arc::clone(&v)).await;
400        let got = cache.get("k1").await;
401        assert_eq!(got.unwrap().as_ref(), &42);
402    }
403}