Skip to main content

forgekit_core/
cache.rs

1//! Query caching layer with LRU eviction and TTL expiration.
2//!
3//! This module provides a thread-safe cache for query results to reduce
4//! database load and improve response times.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9use tokio::sync::RwLock;
10
11/// Cache entry with expiration time.
12#[derive(Debug, Clone)]
13struct CacheEntry<V> {
14    /// The cached value.
15    value: V,
16    /// When this entry expires.
17    expires_at: Instant,
18}
19
20/// Thread-safe query cache with LRU eviction.
21///
22/// The `QueryCache` stores query results with a TTL (time-to-live)
23/// and evicts oldest entries when the cache is full.
24///
25/// # Examples
26///
27/// ```no_run
28/// use forgekit_core::cache::QueryCache;
29/// use std::time::Duration;
30///
31/// # #[tokio::main]
32/// # async fn main() -> anyhow::Result<()> {
33/// let cache = QueryCache::<String, String>::new(100, Duration::from_secs(300));
34///
35/// // Insert a value
36/// cache.insert("key".to_string(), "value".to_string()).await;
37///
38/// // Retrieve it
39/// if let Some(value) = cache.get(&"key".to_string()).await {
40///     println!("Cached: {}", value);
41/// }
42/// # Ok(())
43/// # }
44/// ```
45#[derive(Clone)]
46pub struct QueryCache<K, V>
47where
48    K: Clone + Eq + std::hash::Hash + Send + Sync + 'static,
49    V: Clone + Send + Sync + 'static,
50{
51    /// Maximum number of entries.
52    max_size: usize,
53    /// Time-to-live for cache entries.
54    ttl: Duration,
55    /// The underlying cache store.
56    inner: Arc<RwLock<CacheInner<K, V>>>,
57}
58
59/// Internal cache storage.
60#[derive(Debug)]
61struct CacheInner<K, V>
62where
63    K: Clone + Eq + std::hash::Hash,
64    V: Clone,
65{
66    /// Map from key to cached entry.
67    entries: HashMap<K, CacheEntry<V>>,
68    /// Keys in insertion order (for FIFO eviction).
69    keys: Vec<K>,
70}
71
72impl<K, V> QueryCache<K, V>
73where
74    K: Clone + Eq + std::hash::Hash + Send + Sync + 'static,
75    V: Clone + Send + Sync + 'static,
76{
77    /// Creates a new query cache.
78    ///
79    /// # Arguments
80    ///
81    /// * `max_size` - Maximum number of entries before eviction
82    /// * `ttl` - Time-to-live for cache entries
83    pub fn new(max_size: usize, ttl: Duration) -> Self {
84        Self {
85            max_size,
86            ttl,
87            inner: Arc::new(RwLock::new(CacheInner {
88                entries: HashMap::new(),
89                keys: Vec::new(),
90            })),
91        }
92    }
93
94    /// Gets a cached value if it exists and hasn't expired.
95    ///
96    /// # Arguments
97    ///
98    /// * `key` - The cache key
99    ///
100    /// # Returns
101    ///
102    /// `Some(value)` if cached and valid, `None` otherwise.
103    pub async fn get(&self, key: &K) -> Option<V> {
104        let mut inner = self.inner.write().await;
105        let now = Instant::now();
106
107        // Clone key to avoid borrow issues
108        let key_clone = key.clone();
109        let value_opt = inner.entries.get(&key_clone).cloned();
110
111        if let Some(entry) = value_opt {
112            if now < entry.expires_at {
113                // Touch key: move to end of list (LRU behavior)
114                if let Some(pos) = inner.keys.iter().position(|k| k == &key_clone) {
115                    inner.keys.remove(pos);
116                    inner.keys.push(key_clone);
117                }
118                return Some(entry.value);
119            } else {
120                // Expired - remove it
121                inner.entries.remove(&key_clone);
122                if let Some(pos) = inner.keys.iter().position(|k| k == &key_clone) {
123                    inner.keys.remove(pos);
124                }
125            }
126        }
127        None
128    }
129
130    /// Inserts a value into the cache.
131    ///
132    /// If the cache is full, the oldest entry is evicted (FIFO).
133    ///
134    /// # Arguments
135    ///
136    /// * `key` - The cache key
137    /// * `value` - The value to cache
138    pub async fn insert(&self, key: K, value: V) {
139        let mut inner = self.inner.write().await;
140
141        // If max_size is 0, don't insert anything
142        if self.max_size == 0 {
143            return;
144        }
145
146        // Check if we need to evict
147        while inner.keys.len() >= self.max_size && !inner.keys.is_empty() {
148            // Evict oldest (FIFO) - or first key
149            if let Some(old_key) = inner.keys.first() {
150                let old_key = old_key.clone();
151                inner.keys.remove(0);
152                inner.entries.remove(&old_key);
153            }
154        }
155
156        let expires_at = Instant::now() + self.ttl;
157
158        // Update or insert
159        if !inner.keys.contains(&key) {
160            inner.keys.push(key.clone());
161        }
162        inner.entries.insert(key, CacheEntry { value, expires_at });
163    }
164
165    /// Invalidates a specific cache entry.
166    ///
167    /// # Arguments
168    ///
169    /// * `key` - The cache key to invalidate
170    pub async fn invalidate(&self, key: &K) {
171        let mut inner = self.inner.write().await;
172        inner.entries.remove(key);
173        if let Some(pos) = inner.keys.iter().position(|k| k == key) {
174            inner.keys.remove(pos);
175        }
176    }
177
178    /// Clears all cached entries.
179    pub async fn clear(&self) {
180        let mut inner = self.inner.write().await;
181        inner.entries.clear();
182        inner.keys.clear();
183    }
184
185    /// Returns the current number of cached entries.
186    pub async fn len(&self) -> usize {
187        let inner = self.inner.read().await;
188        inner.entries.len()
189    }
190
191    /// Returns true if the cache is empty.
192    pub async fn is_empty(&self) -> bool {
193        let inner = self.inner.read().await;
194        inner.entries.is_empty()
195    }
196}
197
198impl<K, V> std::fmt::Debug for QueryCache<K, V>
199where
200    K: Clone + Eq + std::hash::Hash + Send + Sync + 'static,
201    V: Clone + Send + Sync + 'static,
202{
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        f.debug_struct("QueryCache")
205            .field("max_size", &self.max_size)
206            .field("ttl", &self.ttl)
207            .field("inner", &"<cache>")
208            .finish()
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use std::time::Duration;
216
217    #[tokio::test]
218    async fn test_cache_insert_get() {
219        let cache = QueryCache::new(10, Duration::from_secs(60));
220
221        cache.insert("key1".to_string(), "value1".to_string()).await;
222        let value = cache.get(&"key1".to_string()).await;
223
224        assert_eq!(value, Some("value1".to_string()));
225    }
226
227    #[tokio::test]
228    async fn test_cache_miss() {
229        let cache = QueryCache::new(10, Duration::from_secs(60));
230
231        let value: Option<String> = cache.get(&"nonexistent".to_string()).await;
232        assert!(value.is_none());
233    }
234
235    #[tokio::test]
236    async fn test_cache_expiration() {
237        let cache = QueryCache::new(10, Duration::from_millis(50));
238
239        cache.insert("key".to_string(), "value".to_string()).await;
240
241        // Wait for expiration
242        tokio::time::sleep(Duration::from_millis(100)).await;
243
244        let value = cache.get(&"key".to_string()).await;
245        assert!(value.is_none());
246    }
247
248    #[tokio::test]
249    async fn test_cache_eviction() {
250        let cache = QueryCache::new(2, Duration::from_secs(60));
251
252        cache.insert("key1".to_string(), "value1".to_string()).await;
253        cache.insert("key2".to_string(), "value2".to_string()).await;
254        cache.insert("key3".to_string(), "value3".to_string()).await;
255
256        // key1 should be evicted (FIFO)
257        assert_eq!(cache.len().await, 2);
258        assert!(cache.get(&"key1".to_string()).await.is_none());
259        assert_eq!(
260            cache.get(&"key2".to_string()).await,
261            Some("value2".to_string())
262        );
263        assert_eq!(
264            cache.get(&"key3".to_string()).await,
265            Some("value3".to_string())
266        );
267    }
268
269    #[tokio::test]
270    async fn test_cache_invalidate() {
271        let cache = QueryCache::new(10, Duration::from_secs(60));
272
273        cache.insert("key".to_string(), "value".to_string()).await;
274        cache.invalidate(&"key".to_string()).await;
275
276        assert!(cache.get(&"key".to_string()).await.is_none());
277        assert_eq!(cache.len().await, 0);
278    }
279
280    #[tokio::test]
281    async fn test_cache_clear() {
282        let cache = QueryCache::new(10, Duration::from_secs(60));
283
284        cache.insert("key1".to_string(), "value1".to_string()).await;
285        cache.insert("key2".to_string(), "value2".to_string()).await;
286        cache.clear().await;
287
288        assert!(cache.is_empty().await);
289    }
290
291    #[tokio::test]
292    async fn test_cache_lru_touch() {
293        let cache = QueryCache::new(3, Duration::from_secs(60));
294
295        // Insert items 1, 2, 3
296        cache.insert("key1".to_string(), "value1".to_string()).await;
297        cache.insert("key2".to_string(), "value2".to_string()).await;
298        cache.insert("key3".to_string(), "value3".to_string()).await;
299
300        // Access item 1 (moves to end)
301        let _ = cache.get(&"key1".to_string()).await;
302
303        // Insert item 4 (causes eviction of oldest, which should be key2)
304        cache.insert("key4".to_string(), "value4".to_string()).await;
305
306        // Verify key2 is evicted (not key1 which was touched)
307        assert_eq!(cache.len().await, 3);
308        assert!(cache.get(&"key2".to_string()).await.is_none());
309        assert_eq!(
310            cache.get(&"key1".to_string()).await,
311            Some("value1".to_string())
312        );
313        assert_eq!(
314            cache.get(&"key3".to_string()).await,
315            Some("value3".to_string())
316        );
317        assert_eq!(
318            cache.get(&"key4".to_string()).await,
319            Some("value4".to_string())
320        );
321    }
322
323    #[tokio::test]
324    async fn test_cache_update_existing() {
325        let cache = QueryCache::new(10, Duration::from_millis(100));
326
327        // Insert key with value A
328        cache.insert("key".to_string(), "valueA".to_string()).await;
329
330        // Wait partial TTL
331        tokio::time::sleep(Duration::from_millis(50)).await;
332
333        // Insert same key with value B (refreshes TTL)
334        cache.insert("key".to_string(), "valueB".to_string()).await;
335
336        // Immediately get key - should return B with fresh TTL
337        assert_eq!(
338            cache.get(&"key".to_string()).await,
339            Some("valueB".to_string())
340        );
341
342        // Wait for original TTL to pass (100ms total)
343        tokio::time::sleep(Duration::from_millis(60)).await;
344
345        // Should still be valid because update refreshed TTL
346        assert_eq!(
347            cache.get(&"key".to_string()).await,
348            Some("valueB".to_string())
349        );
350    }
351
352    #[tokio::test]
353    async fn test_cache_concurrent_access() {
354        let cache = QueryCache::new(20, Duration::from_secs(60));
355        let mut handles = vec![];
356
357        // Spawn 10 tasks concurrently inserting different keys
358        for i in 0..10 {
359            let cache_clone = cache.clone();
360            handles.push(tokio::spawn(async move {
361                cache_clone
362                    .insert(format!("key{}", i), format!("value{}", i))
363                    .await;
364            }));
365        }
366
367        // Wait for all to complete
368        for handle in handles {
369            handle.await.unwrap();
370        }
371
372        // Verify all 10 items are in cache
373        assert_eq!(cache.len().await, 10);
374        for i in 0..10 {
375            assert_eq!(
376                cache.get(&format!("key{}", i)).await,
377                Some(format!("value{}", i))
378            );
379        }
380    }
381
382    #[tokio::test]
383    async fn test_cache_stress_eviction() {
384        let cache = QueryCache::new(5, Duration::from_secs(60));
385
386        // Insert 100 items sequentially
387        for i in 0..100 {
388            cache
389                .insert(format!("key{}", i), format!("value{}", i))
390                .await;
391        }
392
393        // Verify only 5 items remain
394        assert_eq!(cache.len().await, 5);
395
396        // Verify remaining are the last 5 inserted
397        assert!(cache.get(&"key0".to_string()).await.is_none());
398        assert!(cache.get(&"key94".to_string()).await.is_none());
399        assert_eq!(
400            cache.get(&"key95".to_string()).await,
401            Some("value95".to_string())
402        );
403        assert_eq!(
404            cache.get(&"key96".to_string()).await,
405            Some("value96".to_string())
406        );
407        assert_eq!(
408            cache.get(&"key97".to_string()).await,
409            Some("value97".to_string())
410        );
411        assert_eq!(
412            cache.get(&"key98".to_string()).await,
413            Some("value98".to_string())
414        );
415        assert_eq!(
416            cache.get(&"key99".to_string()).await,
417            Some("value99".to_string())
418        );
419    }
420
421    #[tokio::test]
422    async fn test_cache_zero_max_size() {
423        let cache = QueryCache::new(0, Duration::from_secs(60));
424
425        // Insert should not add entries
426        cache.insert("key1".to_string(), "value1".to_string()).await;
427        cache.insert("key2".to_string(), "value2".to_string()).await;
428
429        // Verify len() always returns 0
430        assert_eq!(cache.len().await, 0);
431        assert!(cache.is_empty().await);
432        assert!(cache.get(&"key1".to_string()).await.is_none());
433        assert!(cache.get(&"key2".to_string()).await.is_none());
434    }
435
436    #[tokio::test]
437    async fn test_cache_ttl_expiration() {
438        let cache = QueryCache::new(10, Duration::from_millis(100));
439
440        // Insert key
441        cache.insert("key".to_string(), "value".to_string()).await;
442        assert_eq!(cache.len().await, 1);
443
444        // Wait for expiration
445        tokio::time::sleep(Duration::from_millis(150)).await;
446
447        // Verify get returns None (expired)
448        assert!(cache.get(&"key".to_string()).await.is_none());
449
450        // Verify len decreased
451        assert_eq!(cache.len().await, 0);
452    }
453}