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///
22/// Each entry carries a flag recording whether the verification that inserted
23/// it ran the full client-PUT check set (`true`) or only the
24/// receipt-authenticity subset used for replication (`false`). A
25/// replication-verified entry must not satisfy a later client-PUT fast-path —
26/// the context-gated checks (own-quote freshness, local recipient, merkle
27/// candidate closeness) were never run for it — while either kind of entry
28/// satisfies a later replication check.
29#[derive(Clone)]
30pub struct VerifiedCache {
31    /// Value: `true` if the entry was verified under the full client-PUT
32    /// check set, `false` if only under the replication subset.
33    inner: Arc<Mutex<LruCache<XorName, bool>>>,
34    hits: Arc<AtomicU64>,
35    misses: Arc<AtomicU64>,
36    additions: Arc<AtomicU64>,
37}
38
39/// Cache statistics for monitoring.
40#[derive(Debug, Default, Clone, Copy)]
41pub struct CacheStats {
42    /// Number of cache hits.
43    pub hits: u64,
44    /// Number of cache misses.
45    pub misses: u64,
46    /// Number of entries added.
47    pub additions: u64,
48}
49
50impl CacheStats {
51    /// Calculate hit rate as a percentage.
52    #[must_use]
53    #[allow(clippy::cast_precision_loss)]
54    pub fn hit_rate(&self) -> f64 {
55        let total = self.hits + self.misses;
56        if total == 0 {
57            0.0
58        } else {
59            (self.hits as f64 / total as f64) * 100.0
60        }
61    }
62}
63
64impl VerifiedCache {
65    /// Create a new cache with default capacity.
66    #[must_use]
67    pub fn new() -> Self {
68        Self::with_capacity(DEFAULT_CACHE_CAPACITY)
69    }
70
71    /// Create a new cache with the specified capacity.
72    ///
73    /// If capacity is 0, defaults to 1.
74    #[must_use]
75    pub fn with_capacity(capacity: usize) -> Self {
76        // Use max(1, capacity) to ensure non-zero, avoiding unsafe or expect
77        let effective_capacity = capacity.max(1);
78        // This is guaranteed to succeed since effective_capacity >= 1
79        // Using if-let pattern since we know it will always be Some
80        let cap = NonZeroUsize::new(effective_capacity).unwrap_or(NonZeroUsize::MIN);
81        Self {
82            inner: Arc::new(Mutex::new(LruCache::new(cap))),
83            hits: Arc::new(AtomicU64::new(0)),
84            misses: Arc::new(AtomicU64::new(0)),
85            additions: Arc::new(AtomicU64::new(0)),
86        }
87    }
88
89    /// Check if a `XorName` is in the cache (verified under either check set).
90    ///
91    /// Returns `true` if the `XorName` is cached (verified to exist on autonomi).
92    /// Sufficient for replication-context lookups; client-PUT lookups must use
93    /// [`Self::contains_client_put_verified`].
94    #[must_use]
95    pub fn contains(&self, xorname: &XorName) -> bool {
96        let found = self.inner.lock().get(xorname).is_some();
97
98        if found {
99            self.hits.fetch_add(1, Ordering::Relaxed);
100        } else {
101            self.misses.fetch_add(1, Ordering::Relaxed);
102        }
103
104        found
105    }
106
107    /// Check if a `XorName` is cached AND its verification ran the full
108    /// client-PUT check set.
109    ///
110    /// A replication-verified entry returns `false` here: it never passed the
111    /// client-PUT-only checks, so it must not let a later client PUT skip them.
112    #[must_use]
113    pub fn contains_client_put_verified(&self, xorname: &XorName) -> bool {
114        let found = self.inner.lock().get(xorname).copied() == Some(true);
115
116        if found {
117            self.hits.fetch_add(1, Ordering::Relaxed);
118        } else {
119            self.misses.fetch_add(1, Ordering::Relaxed);
120        }
121
122        found
123    }
124
125    /// Add a `XorName` verified under the full client-PUT check set.
126    ///
127    /// This should be called after verifying that data exists on the autonomi network.
128    /// Also upgrades an existing replication-verified entry.
129    pub fn insert(&self, xorname: XorName) {
130        self.inner.lock().put(xorname, true);
131        self.additions.fetch_add(1, Ordering::Relaxed);
132    }
133
134    /// Add a `XorName` verified under the replication (receipt-authenticity)
135    /// subset only.
136    ///
137    /// Never downgrades an existing client-PUT-verified entry — the stronger
138    /// verification already happened, and replication re-offers of the same
139    /// key are routine.
140    pub fn insert_replication_verified(&self, xorname: XorName) {
141        let added = {
142            let mut inner = self.inner.lock();
143            // `get_mut` refreshes LRU recency for existing entries of either kind.
144            if inner.get_mut(&xorname).is_none() {
145                inner.put(xorname, false);
146                true
147            } else {
148                false
149            }
150        };
151        if added {
152            self.additions.fetch_add(1, Ordering::Relaxed);
153        }
154    }
155
156    /// Get current cache statistics.
157    #[must_use]
158    pub fn stats(&self) -> CacheStats {
159        CacheStats {
160            hits: self.hits.load(Ordering::Relaxed),
161            misses: self.misses.load(Ordering::Relaxed),
162            additions: self.additions.load(Ordering::Relaxed),
163        }
164    }
165
166    /// Get the current number of entries in the cache.
167    #[must_use]
168    pub fn len(&self) -> usize {
169        self.inner.lock().len()
170    }
171
172    /// Check if the cache is empty.
173    #[must_use]
174    pub fn is_empty(&self) -> bool {
175        self.inner.lock().is_empty()
176    }
177
178    /// Clear all entries from the cache.
179    pub fn clear(&self) {
180        self.inner.lock().clear();
181    }
182}
183
184impl Default for VerifiedCache {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190#[cfg(test)]
191#[allow(clippy::expect_used)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_cache_basic_operations() {
197        let cache = VerifiedCache::new();
198
199        let xorname1 = [1u8; 32];
200        let xorname2 = [2u8; 32];
201
202        // Initially empty
203        assert!(cache.is_empty());
204        assert!(!cache.contains(&xorname1));
205
206        // Insert and check
207        cache.insert(xorname1);
208        assert!(cache.contains(&xorname1));
209        assert!(!cache.contains(&xorname2));
210        assert_eq!(cache.len(), 1);
211
212        // Insert another
213        cache.insert(xorname2);
214        assert!(cache.contains(&xorname1));
215        assert!(cache.contains(&xorname2));
216        assert_eq!(cache.len(), 2);
217    }
218
219    #[test]
220    fn test_cache_stats() {
221        let cache = VerifiedCache::new();
222        let xorname = [1u8; 32];
223
224        // Miss
225        assert!(!cache.contains(&xorname));
226        let stats = cache.stats();
227        assert_eq!(stats.misses, 1);
228        assert_eq!(stats.hits, 0);
229
230        // Add
231        cache.insert(xorname);
232        let stats = cache.stats();
233        assert_eq!(stats.additions, 1);
234
235        // Hit
236        assert!(cache.contains(&xorname));
237        let stats = cache.stats();
238        assert_eq!(stats.hits, 1);
239        assert_eq!(stats.misses, 1);
240
241        // Hit rate should be 50%
242        assert!((stats.hit_rate() - 50.0).abs() < 0.01);
243    }
244
245    #[test]
246    fn test_cache_lru_eviction() {
247        // Small cache for testing eviction
248        let cache = VerifiedCache::with_capacity(2);
249
250        let xorname1 = [1u8; 32];
251        let xorname2 = [2u8; 32];
252        let xorname3 = [3u8; 32];
253
254        cache.insert(xorname1);
255        cache.insert(xorname2);
256        assert_eq!(cache.len(), 2);
257
258        // Insert third, should evict xorname1 (least recently used)
259        cache.insert(xorname3);
260        assert_eq!(cache.len(), 2);
261        assert!(!cache.contains(&xorname1)); // evicted
262                                             // Note: after contains call on evicted item, stats will show a miss
263    }
264
265    #[test]
266    fn test_cache_clear() {
267        let cache = VerifiedCache::new();
268
269        cache.insert([1u8; 32]);
270        cache.insert([2u8; 32]);
271        assert_eq!(cache.len(), 2);
272
273        cache.clear();
274        assert!(cache.is_empty());
275    }
276
277    #[test]
278    fn test_with_capacity_zero_defaults_to_one() {
279        let cache = VerifiedCache::with_capacity(0);
280        // Should be able to store at least 1 element
281        cache.insert([1u8; 32]);
282        assert_eq!(cache.len(), 1);
283    }
284
285    #[test]
286    fn test_default_impl() {
287        let cache = VerifiedCache::default();
288        assert!(cache.is_empty());
289        cache.insert([1u8; 32]);
290        assert!(cache.contains(&[1u8; 32]));
291    }
292
293    #[test]
294    fn test_hit_rate_zero_total() {
295        let stats = CacheStats::default();
296        assert!(stats.hit_rate().abs() < f64::EPSILON);
297    }
298
299    #[test]
300    fn test_hit_rate_all_hits() {
301        let stats = CacheStats {
302            hits: 10,
303            misses: 0,
304            additions: 0,
305        };
306        assert!((stats.hit_rate() - 100.0).abs() < 0.01);
307    }
308
309    #[test]
310    fn test_hit_rate_all_misses() {
311        let stats = CacheStats {
312            hits: 0,
313            misses: 10,
314            additions: 0,
315        };
316        assert!(stats.hit_rate().abs() < f64::EPSILON);
317    }
318
319    #[test]
320    fn test_clear_does_not_reset_stats() {
321        let cache = VerifiedCache::new();
322        cache.insert([1u8; 32]);
323        let _ = cache.contains(&[1u8; 32]); // hit
324        let _ = cache.contains(&[2u8; 32]); // miss
325
326        cache.clear();
327
328        // Stats should persist after clear
329        let stats = cache.stats();
330        assert_eq!(stats.hits, 1);
331        assert_eq!(stats.misses, 1);
332        assert_eq!(stats.additions, 1);
333    }
334
335    #[test]
336    fn test_concurrent_insert_and_contains() {
337        use std::sync::Arc;
338        use std::thread;
339
340        let cache = Arc::new(VerifiedCache::with_capacity(1000));
341        let mut handles = Vec::new();
342
343        // 10 threads inserting
344        for i in 0..10u8 {
345            let c = cache.clone();
346            handles.push(thread::spawn(move || {
347                let xorname = [i; 32];
348                c.insert(xorname);
349            }));
350        }
351
352        // 10 threads checking
353        for i in 0..10u8 {
354            let c = cache.clone();
355            handles.push(thread::spawn(move || {
356                let xorname = [i; 32];
357                let _ = c.contains(&xorname);
358            }));
359        }
360
361        for handle in handles {
362            handle.join().expect("thread panicked");
363        }
364
365        // All 10 should have been inserted
366        assert_eq!(cache.len(), 10);
367    }
368
369    #[test]
370    fn test_cache_stats_copy() {
371        let stats = CacheStats {
372            hits: 5,
373            misses: 3,
374            additions: 8,
375        };
376        let stats2 = stats; // Copy
377        assert_eq!(stats.hits, stats2.hits);
378        assert_eq!(stats.misses, stats2.misses);
379        assert_eq!(stats.additions, stats2.additions);
380    }
381}