Skip to main content

ant_node/payment/
cache.rs

1//! LRU cache for verified `XorName` values.
2//!
3//! Caches `XorName` values that have been verified to exist on the autonomi network,
4//! reducing the number of network queries needed for repeated/popular data.
5
6use lru::LruCache;
7use parking_lot::Mutex;
8use std::num::NonZeroUsize;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::Arc;
11
12pub use super::quote::XorName;
13
14/// Default cache capacity (100,000 entries = 3.2MB memory).
15const DEFAULT_CACHE_CAPACITY: usize = 100_000;
16
17/// LRU cache for verified `XorName` values.
18///
19/// This cache stores `XorName` values that have been verified to exist on the
20/// autonomi network, avoiding repeated network queries for the same data.
21#[derive(Clone)]
22pub struct VerifiedCache {
23    inner: Arc<Mutex<LruCache<XorName, ()>>>,
24    hits: Arc<AtomicU64>,
25    misses: Arc<AtomicU64>,
26    additions: Arc<AtomicU64>,
27}
28
29/// Cache statistics for monitoring.
30#[derive(Debug, Default, Clone, Copy)]
31pub struct CacheStats {
32    /// Number of cache hits.
33    pub hits: u64,
34    /// Number of cache misses.
35    pub misses: u64,
36    /// Number of entries added.
37    pub additions: u64,
38}
39
40impl CacheStats {
41    /// Calculate hit rate as a percentage.
42    #[must_use]
43    #[allow(clippy::cast_precision_loss)]
44    pub fn hit_rate(&self) -> f64 {
45        let total = self.hits + self.misses;
46        if total == 0 {
47            0.0
48        } else {
49            (self.hits as f64 / total as f64) * 100.0
50        }
51    }
52}
53
54impl VerifiedCache {
55    /// Create a new cache with default capacity.
56    #[must_use]
57    pub fn new() -> Self {
58        Self::with_capacity(DEFAULT_CACHE_CAPACITY)
59    }
60
61    /// Create a new cache with the specified capacity.
62    ///
63    /// If capacity is 0, defaults to 1.
64    #[must_use]
65    pub fn with_capacity(capacity: usize) -> Self {
66        // Use max(1, capacity) to ensure non-zero, avoiding unsafe or expect
67        let effective_capacity = capacity.max(1);
68        // This is guaranteed to succeed since effective_capacity >= 1
69        // Using if-let pattern since we know it will always be Some
70        let cap = NonZeroUsize::new(effective_capacity).unwrap_or(NonZeroUsize::MIN);
71        Self {
72            inner: Arc::new(Mutex::new(LruCache::new(cap))),
73            hits: Arc::new(AtomicU64::new(0)),
74            misses: Arc::new(AtomicU64::new(0)),
75            additions: Arc::new(AtomicU64::new(0)),
76        }
77    }
78
79    /// Check if a `XorName` is in the cache.
80    ///
81    /// Returns `true` if the `XorName` is cached (verified to exist on autonomi).
82    #[must_use]
83    pub fn contains(&self, xorname: &XorName) -> bool {
84        let found = self.inner.lock().get(xorname).is_some();
85
86        if found {
87            self.hits.fetch_add(1, Ordering::Relaxed);
88        } else {
89            self.misses.fetch_add(1, Ordering::Relaxed);
90        }
91
92        found
93    }
94
95    /// Add a `XorName` to the cache.
96    ///
97    /// This should be called after verifying that data exists on the autonomi network.
98    pub fn insert(&self, xorname: XorName) {
99        self.inner.lock().put(xorname, ());
100        self.additions.fetch_add(1, Ordering::Relaxed);
101    }
102
103    /// Get current cache statistics.
104    #[must_use]
105    pub fn stats(&self) -> CacheStats {
106        CacheStats {
107            hits: self.hits.load(Ordering::Relaxed),
108            misses: self.misses.load(Ordering::Relaxed),
109            additions: self.additions.load(Ordering::Relaxed),
110        }
111    }
112
113    /// Get the current number of entries in the cache.
114    #[must_use]
115    pub fn len(&self) -> usize {
116        self.inner.lock().len()
117    }
118
119    /// Check if the cache is empty.
120    #[must_use]
121    pub fn is_empty(&self) -> bool {
122        self.inner.lock().is_empty()
123    }
124
125    /// Clear all entries from the cache.
126    pub fn clear(&self) {
127        self.inner.lock().clear();
128    }
129}
130
131impl Default for VerifiedCache {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137#[cfg(test)]
138#[allow(clippy::expect_used)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn test_cache_basic_operations() {
144        let cache = VerifiedCache::new();
145
146        let xorname1 = [1u8; 32];
147        let xorname2 = [2u8; 32];
148
149        // Initially empty
150        assert!(cache.is_empty());
151        assert!(!cache.contains(&xorname1));
152
153        // Insert and check
154        cache.insert(xorname1);
155        assert!(cache.contains(&xorname1));
156        assert!(!cache.contains(&xorname2));
157        assert_eq!(cache.len(), 1);
158
159        // Insert another
160        cache.insert(xorname2);
161        assert!(cache.contains(&xorname1));
162        assert!(cache.contains(&xorname2));
163        assert_eq!(cache.len(), 2);
164    }
165
166    #[test]
167    fn test_cache_stats() {
168        let cache = VerifiedCache::new();
169        let xorname = [1u8; 32];
170
171        // Miss
172        assert!(!cache.contains(&xorname));
173        let stats = cache.stats();
174        assert_eq!(stats.misses, 1);
175        assert_eq!(stats.hits, 0);
176
177        // Add
178        cache.insert(xorname);
179        let stats = cache.stats();
180        assert_eq!(stats.additions, 1);
181
182        // Hit
183        assert!(cache.contains(&xorname));
184        let stats = cache.stats();
185        assert_eq!(stats.hits, 1);
186        assert_eq!(stats.misses, 1);
187
188        // Hit rate should be 50%
189        assert!((stats.hit_rate() - 50.0).abs() < 0.01);
190    }
191
192    #[test]
193    fn test_cache_lru_eviction() {
194        // Small cache for testing eviction
195        let cache = VerifiedCache::with_capacity(2);
196
197        let xorname1 = [1u8; 32];
198        let xorname2 = [2u8; 32];
199        let xorname3 = [3u8; 32];
200
201        cache.insert(xorname1);
202        cache.insert(xorname2);
203        assert_eq!(cache.len(), 2);
204
205        // Insert third, should evict xorname1 (least recently used)
206        cache.insert(xorname3);
207        assert_eq!(cache.len(), 2);
208        assert!(!cache.contains(&xorname1)); // evicted
209                                             // Note: after contains call on evicted item, stats will show a miss
210    }
211
212    #[test]
213    fn test_cache_clear() {
214        let cache = VerifiedCache::new();
215
216        cache.insert([1u8; 32]);
217        cache.insert([2u8; 32]);
218        assert_eq!(cache.len(), 2);
219
220        cache.clear();
221        assert!(cache.is_empty());
222    }
223
224    #[test]
225    fn test_with_capacity_zero_defaults_to_one() {
226        let cache = VerifiedCache::with_capacity(0);
227        // Should be able to store at least 1 element
228        cache.insert([1u8; 32]);
229        assert_eq!(cache.len(), 1);
230    }
231
232    #[test]
233    fn test_default_impl() {
234        let cache = VerifiedCache::default();
235        assert!(cache.is_empty());
236        cache.insert([1u8; 32]);
237        assert!(cache.contains(&[1u8; 32]));
238    }
239
240    #[test]
241    fn test_hit_rate_zero_total() {
242        let stats = CacheStats::default();
243        assert!(stats.hit_rate().abs() < f64::EPSILON);
244    }
245
246    #[test]
247    fn test_hit_rate_all_hits() {
248        let stats = CacheStats {
249            hits: 10,
250            misses: 0,
251            additions: 0,
252        };
253        assert!((stats.hit_rate() - 100.0).abs() < 0.01);
254    }
255
256    #[test]
257    fn test_hit_rate_all_misses() {
258        let stats = CacheStats {
259            hits: 0,
260            misses: 10,
261            additions: 0,
262        };
263        assert!(stats.hit_rate().abs() < f64::EPSILON);
264    }
265
266    #[test]
267    fn test_clear_does_not_reset_stats() {
268        let cache = VerifiedCache::new();
269        cache.insert([1u8; 32]);
270        let _ = cache.contains(&[1u8; 32]); // hit
271        let _ = cache.contains(&[2u8; 32]); // miss
272
273        cache.clear();
274
275        // Stats should persist after clear
276        let stats = cache.stats();
277        assert_eq!(stats.hits, 1);
278        assert_eq!(stats.misses, 1);
279        assert_eq!(stats.additions, 1);
280    }
281
282    #[test]
283    fn test_concurrent_insert_and_contains() {
284        use std::sync::Arc;
285        use std::thread;
286
287        let cache = Arc::new(VerifiedCache::with_capacity(1000));
288        let mut handles = Vec::new();
289
290        // 10 threads inserting
291        for i in 0..10u8 {
292            let c = cache.clone();
293            handles.push(thread::spawn(move || {
294                let xorname = [i; 32];
295                c.insert(xorname);
296            }));
297        }
298
299        // 10 threads checking
300        for i in 0..10u8 {
301            let c = cache.clone();
302            handles.push(thread::spawn(move || {
303                let xorname = [i; 32];
304                let _ = c.contains(&xorname);
305            }));
306        }
307
308        for handle in handles {
309            handle.join().expect("thread panicked");
310        }
311
312        // All 10 should have been inserted
313        assert_eq!(cache.len(), 10);
314    }
315
316    #[test]
317    fn test_cache_stats_copy() {
318        let stats = CacheStats {
319            hits: 5,
320            misses: 3,
321            additions: 8,
322        };
323        let stats2 = stats; // Copy
324        assert_eq!(stats.hits, stats2.hits);
325        assert_eq!(stats.misses, stats2.misses);
326        assert_eq!(stats.additions, stats2.additions);
327    }
328}