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    hits: AtomicU64,
14    misses: AtomicU64,
15}
16
17impl Cache {
18    /// Creates a new `Cache` with the specified capacity.
19    ///
20    /// # Arguments
21    ///
22    /// * `capacity` - The maximum number of items the cache can hold. Minimum capacity is 1.
23    pub fn new(capacity: usize) -> Self {
24        // Ensure capacity is at least 1 to avoid panic
25        let safe_capacity = capacity.max(1);
26        Self {
27            inner: Mutex::new(LruCache::new(
28                NonZeroUsize::new(safe_capacity).expect("capacity should be non-zero after max(1)"),
29            )),
30            hits: AtomicU64::new(0),
31            misses: AtomicU64::new(0),
32        }
33    }
34
35    /// Retrieves an ID from the cache if it exists.
36    ///
37    /// # Arguments
38    ///
39    /// * `key` - The canonicalised JSON string key.
40    ///
41    /// # Returns
42    ///
43    /// `Some(i32)` if the key exists, `None` otherwise.
44    /// Returns `None` if the cache mutex is poisoned (treated as cache miss).
45    pub fn get(&self, key: &str) -> Option<i32> {
46        // Handle poisoned mutex gracefully by treating it as a cache miss
47        let mut cache = self.inner.lock().ok()?;
48        let result = cache.get(key).copied();
49
50        if result.is_some() {
51            self.hits.fetch_add(1, Ordering::Relaxed);
52        } else {
53            self.misses.fetch_add(1, Ordering::Relaxed);
54        }
55
56        result
57    }
58
59    /// Inserts a key-value pair into the cache.
60    ///
61    /// # Arguments
62    ///
63    /// * `key` - The canonicalised JSON string key.
64    /// * `value` - The database ID associated with the key.
65    ///
66    /// If the cache mutex is poisoned, the operation is silently skipped.
67    pub fn put(&self, key: String, value: i32) {
68        // Handle poisoned mutex gracefully by skipping the cache update
69        if let Ok(mut cache) = self.inner.lock() {
70            cache.put(key, value);
71        }
72    }
73
74    /// Returns the number of cache hits.
75    ///
76    /// # Returns
77    ///
78    /// The total number of successful cache lookups.
79    pub fn hits(&self) -> u64 {
80        self.hits.load(Ordering::Relaxed)
81    }
82
83    /// Returns the number of cache misses.
84    ///
85    /// # Returns
86    ///
87    /// The total number of unsuccessful cache lookups.
88    pub fn misses(&self) -> u64 {
89        self.misses.load(Ordering::Relaxed)
90    }
91
92    /// Returns the cache hit rate as a percentage.
93    ///
94    /// # Returns
95    ///
96    /// The hit rate as a float between 0.0 and 100.0.
97    /// Returns 0.0 if no cache operations have occurred.
98    pub fn hit_rate(&self) -> f64 {
99        let hits = self.hits();
100        let misses = self.misses();
101        let total = hits + misses;
102
103        if total == 0 {
104            0.0
105        } else {
106            (hits as f64 / total as f64) * 100.0
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_cache_zero_capacity_does_not_panic() {
117        // Verifies that creating a cache with capacity 0 doesn't panic
118        // and is automatically adjusted to minimum capacity of 1
119        let cache = Cache::new(0);
120        cache.put("test".to_string(), 42);
121        assert_eq!(cache.get("test"), Some(42));
122    }
123
124    #[test]
125    fn test_cache_basic_operations() {
126        // Verifies basic cache get/put operations
127        let cache = Cache::new(10);
128
129        assert_eq!(cache.get("key1"), None);
130
131        cache.put("key1".to_string(), 100);
132        assert_eq!(cache.get("key1"), Some(100));
133
134        cache.put("key2".to_string(), 200);
135        assert_eq!(cache.get("key2"), Some(200));
136        assert_eq!(cache.get("key1"), Some(100));
137    }
138
139    #[test]
140    fn test_cache_lru_eviction() {
141        // Verifies that LRU eviction works correctly with small capacity
142        let cache = Cache::new(2);
143
144        cache.put("key1".to_string(), 1);
145        cache.put("key2".to_string(), 2);
146        cache.put("key3".to_string(), 3); // Should evict key1
147
148        assert_eq!(cache.get("key1"), None); // Evicted
149        assert_eq!(cache.get("key2"), Some(2));
150        assert_eq!(cache.get("key3"), Some(3));
151    }
152
153    #[test]
154    fn test_cache_hit_miss_tracking() {
155        // Verifies that cache hit/miss statistics are tracked correctly
156        let cache = Cache::new(10);
157
158        // Initially, no hits or misses
159        assert_eq!(cache.hits(), 0);
160        assert_eq!(cache.misses(), 0);
161        assert_eq!(cache.hit_rate(), 0.0);
162
163        // First lookup should be a miss
164        assert_eq!(cache.get("key1"), None);
165        assert_eq!(cache.hits(), 0);
166        assert_eq!(cache.misses(), 1);
167        assert_eq!(cache.hit_rate(), 0.0);
168
169        // Add an entry
170        cache.put("key1".to_string(), 100);
171
172        // Second lookup should be a hit
173        assert_eq!(cache.get("key1"), Some(100));
174        assert_eq!(cache.hits(), 1);
175        assert_eq!(cache.misses(), 1);
176        assert_eq!(cache.hit_rate(), 50.0);
177
178        // Another hit
179        assert_eq!(cache.get("key1"), Some(100));
180        assert_eq!(cache.hits(), 2);
181        assert_eq!(cache.misses(), 1);
182        assert!((cache.hit_rate() - 66.666).abs() < 0.01);
183
184        // Another miss
185        assert_eq!(cache.get("key2"), None);
186        assert_eq!(cache.hits(), 2);
187        assert_eq!(cache.misses(), 2);
188        assert_eq!(cache.hit_rate(), 50.0);
189    }
190}