json_register/
cache.rs

1use lru::LruCache;
2use std::num::NonZeroUsize;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Mutex;
5
6/// A thread-safe Least Recently Used (LRU) cache.
7///
8/// This struct wraps an `LruCache` in a `Mutex` to allow concurrent access
9/// from multiple threads. It maps canonicalised JSON strings to their
10/// corresponding database IDs. It also tracks hit and miss statistics.
11pub struct Cache {
12    inner: Mutex<LruCache<String, i32>>,
13    capacity: usize,
14    hits: AtomicU64,
15    misses: AtomicU64,
16    evictions: AtomicU64,
17}
18
19impl Cache {
20    /// Creates a new `Cache` with the specified capacity.
21    ///
22    /// # Arguments
23    ///
24    /// * `capacity` - The maximum number of items the cache can hold. Minimum capacity is 1.
25    pub fn new(capacity: usize) -> Self {
26        // Ensure capacity is at least 1 to avoid panic
27        let safe_capacity = capacity.max(1);
28        Self {
29            inner: Mutex::new(LruCache::new(
30                NonZeroUsize::new(safe_capacity).expect("capacity should be non-zero after max(1)"),
31            )),
32            capacity: safe_capacity,
33            hits: AtomicU64::new(0),
34            misses: AtomicU64::new(0),
35            evictions: AtomicU64::new(0),
36        }
37    }
38
39    /// Retrieves an ID from the cache if it exists.
40    ///
41    /// # Arguments
42    ///
43    /// * `key` - The canonicalised JSON string key.
44    ///
45    /// # Returns
46    ///
47    /// `Some(i32)` if the key exists, `None` otherwise.
48    /// Returns `None` if the cache mutex is poisoned (treated as cache miss).
49    pub fn get(&self, key: &str) -> Option<i32> {
50        // Handle poisoned mutex gracefully by treating it as a cache miss
51        let mut cache = self.inner.lock().ok()?;
52        let result = cache.get(key).copied();
53
54        if result.is_some() {
55            self.hits.fetch_add(1, Ordering::Relaxed);
56        } else {
57            self.misses.fetch_add(1, Ordering::Relaxed);
58        }
59
60        result
61    }
62
63    /// Inserts a key-value pair into the cache.
64    ///
65    /// # Arguments
66    ///
67    /// * `key` - The canonicalised JSON string key.
68    /// * `value` - The database ID associated with the key.
69    ///
70    /// If the cache mutex is poisoned, the operation is silently skipped.
71    pub fn put(&self, key: String, value: i32) {
72        // Handle poisoned mutex gracefully by skipping the cache update
73        if let Ok(mut cache) = self.inner.lock() {
74            // Track eviction if cache is at capacity and key doesn't exist
75            if cache.len() >= cache.cap().get() && !cache.contains(&key) {
76                self.evictions.fetch_add(1, Ordering::Relaxed);
77            }
78            cache.put(key, value);
79        }
80    }
81
82    /// Returns the number of cache hits.
83    ///
84    /// # Returns
85    ///
86    /// The total number of successful cache lookups.
87    pub fn hits(&self) -> u64 {
88        self.hits.load(Ordering::Relaxed)
89    }
90
91    /// Returns the number of cache misses.
92    ///
93    /// # Returns
94    ///
95    /// The total number of unsuccessful cache lookups.
96    pub fn misses(&self) -> u64 {
97        self.misses.load(Ordering::Relaxed)
98    }
99
100    /// Returns the cache hit rate as a percentage.
101    ///
102    /// # Returns
103    ///
104    /// The hit rate as a float between 0.0 and 100.0.
105    /// Returns 0.0 if no cache operations have occurred.
106    pub fn hit_rate(&self) -> f64 {
107        let hits = self.hits();
108        let misses = self.misses();
109        let total = hits + misses;
110
111        if total == 0 {
112            0.0
113        } else {
114            (hits as f64 / total as f64) * 100.0
115        }
116    }
117
118    /// Returns the current number of items in the cache.
119    ///
120    /// # Returns
121    ///
122    /// The number of items currently stored in the cache.
123    /// Returns 0 if the cache mutex is poisoned.
124    pub fn size(&self) -> usize {
125        self.inner.lock().ok().map(|cache| cache.len()).unwrap_or(0)
126    }
127
128    /// Returns the maximum capacity of the cache.
129    ///
130    /// # Returns
131    ///
132    /// The maximum number of items the cache can hold.
133    pub fn capacity(&self) -> usize {
134        self.capacity
135    }
136
137    /// Returns the number of cache evictions.
138    ///
139    /// # Returns
140    ///
141    /// The total number of items evicted from the cache.
142    pub fn evictions(&self) -> u64 {
143        self.evictions.load(Ordering::Relaxed)
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_cache_zero_capacity_does_not_panic() {
153        // Verifies that creating a cache with capacity 0 doesn't panic
154        // and is automatically adjusted to minimum capacity of 1
155        let cache = Cache::new(0);
156        cache.put("test".to_string(), 42);
157        assert_eq!(cache.get("test"), Some(42));
158    }
159
160    #[test]
161    fn test_cache_basic_operations() {
162        // Verifies basic cache get/put operations
163        let cache = Cache::new(10);
164
165        assert_eq!(cache.get("key1"), None);
166
167        cache.put("key1".to_string(), 100);
168        assert_eq!(cache.get("key1"), Some(100));
169
170        cache.put("key2".to_string(), 200);
171        assert_eq!(cache.get("key2"), Some(200));
172        assert_eq!(cache.get("key1"), Some(100));
173    }
174
175    #[test]
176    fn test_cache_lru_eviction() {
177        // Verifies that LRU eviction works correctly with small capacity
178        let cache = Cache::new(2);
179
180        cache.put("key1".to_string(), 1);
181        cache.put("key2".to_string(), 2);
182        cache.put("key3".to_string(), 3); // Should evict key1
183
184        assert_eq!(cache.get("key1"), None); // Evicted
185        assert_eq!(cache.get("key2"), Some(2));
186        assert_eq!(cache.get("key3"), Some(3));
187    }
188
189    #[test]
190    fn test_cache_hit_miss_tracking() {
191        // Verifies that cache hit/miss statistics are tracked correctly
192        let cache = Cache::new(10);
193
194        // Initially, no hits or misses
195        assert_eq!(cache.hits(), 0);
196        assert_eq!(cache.misses(), 0);
197        assert_eq!(cache.hit_rate(), 0.0);
198
199        // First lookup should be a miss
200        assert_eq!(cache.get("key1"), None);
201        assert_eq!(cache.hits(), 0);
202        assert_eq!(cache.misses(), 1);
203        assert_eq!(cache.hit_rate(), 0.0);
204
205        // Add an entry
206        cache.put("key1".to_string(), 100);
207
208        // Second lookup should be a hit
209        assert_eq!(cache.get("key1"), Some(100));
210        assert_eq!(cache.hits(), 1);
211        assert_eq!(cache.misses(), 1);
212        assert_eq!(cache.hit_rate(), 50.0);
213
214        // Another hit
215        assert_eq!(cache.get("key1"), Some(100));
216        assert_eq!(cache.hits(), 2);
217        assert_eq!(cache.misses(), 1);
218        assert!((cache.hit_rate() - 66.666).abs() < 0.01);
219
220        // Another miss
221        assert_eq!(cache.get("key2"), None);
222        assert_eq!(cache.hits(), 2);
223        assert_eq!(cache.misses(), 2);
224        assert_eq!(cache.hit_rate(), 50.0);
225    }
226
227    #[test]
228    fn test_cache_eviction_tracking() {
229        // Verifies that cache evictions are tracked correctly
230        let cache = Cache::new(2);
231
232        assert_eq!(cache.evictions(), 0);
233        assert_eq!(cache.size(), 0);
234        assert_eq!(cache.capacity(), 2);
235
236        cache.put("key1".to_string(), 1);
237        assert_eq!(cache.size(), 1);
238        assert_eq!(cache.evictions(), 0);
239
240        cache.put("key2".to_string(), 2);
241        assert_eq!(cache.size(), 2);
242        assert_eq!(cache.evictions(), 0);
243
244        // This should trigger an eviction
245        cache.put("key3".to_string(), 3);
246        assert_eq!(cache.size(), 2);
247        assert_eq!(cache.evictions(), 1);
248
249        // Another eviction
250        cache.put("key4".to_string(), 4);
251        assert_eq!(cache.size(), 2);
252        assert_eq!(cache.evictions(), 2);
253
254        // Updating an existing key should not trigger eviction
255        cache.put("key3".to_string(), 30);
256        assert_eq!(cache.size(), 2);
257        assert_eq!(cache.evictions(), 2);
258    }
259}