Skip to main content

oximedia_cache/
cache_metrics.rs

1//! Cache metrics module: hit/miss rates, latency tracking, eviction counters.
2//!
3//! Provides [`CacheMetrics`] with atomic counters safe for multi-threaded
4//! access, and a [`CacheMetricsSnapshot`] for point-in-time reporting.
5
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::Arc;
8
9// ── CacheMetrics ─────────────────────────────────────────────────────────────
10
11/// Atomic cache metrics counters.
12///
13/// All fields use `AtomicU64` with `Relaxed` ordering for maximum throughput;
14/// consistency is guaranteed only when you call `snapshot`.
15///
16/// # Example
17/// ```rust
18/// use oximedia_cache::cache_metrics::CacheMetrics;
19/// let m = CacheMetrics::new();
20/// m.record_hit(500);
21/// m.record_miss(1_200);
22/// let s = m.snapshot();
23/// assert!((s.hit_rate - 0.5).abs() < 1e-9);
24/// ```
25pub struct CacheMetrics {
26    /// Total number of cache hits.
27    hits: AtomicU64,
28    /// Total number of cache misses.
29    misses: AtomicU64,
30    /// Total number of evictions.
31    evictions: AtomicU64,
32    /// Accumulated hit latency in nanoseconds.
33    total_hit_latency_ns: AtomicU64,
34    /// Accumulated miss latency in nanoseconds.
35    total_miss_latency_ns: AtomicU64,
36    /// Total number of latency samples (hits + misses with latency recorded).
37    latency_samples: AtomicU64,
38}
39
40impl std::fmt::Debug for CacheMetrics {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("CacheMetrics")
43            .field("hits", &self.hits.load(Ordering::Relaxed))
44            .field("misses", &self.misses.load(Ordering::Relaxed))
45            .field("evictions", &self.evictions.load(Ordering::Relaxed))
46            .finish()
47    }
48}
49
50impl Default for CacheMetrics {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl CacheMetrics {
57    /// Create a new zeroed `CacheMetrics` instance.
58    pub fn new() -> Self {
59        Self {
60            hits: AtomicU64::new(0),
61            misses: AtomicU64::new(0),
62            evictions: AtomicU64::new(0),
63            total_hit_latency_ns: AtomicU64::new(0),
64            total_miss_latency_ns: AtomicU64::new(0),
65            latency_samples: AtomicU64::new(0),
66        }
67    }
68
69    /// Create a new `CacheMetrics` wrapped in an `Arc` for sharing across threads.
70    pub fn new_shared() -> Arc<Self> {
71        Arc::new(Self::new())
72    }
73
74    /// Record a cache hit with the given lookup latency in nanoseconds.
75    ///
76    /// Increments the hit counter and accumulates latency for average
77    /// latency calculation.
78    pub fn record_hit(&self, latency_ns: u64) {
79        self.hits.fetch_add(1, Ordering::Relaxed);
80        self.total_hit_latency_ns
81            .fetch_add(latency_ns, Ordering::Relaxed);
82        self.latency_samples.fetch_add(1, Ordering::Relaxed);
83    }
84
85    /// Record a cache miss with the given lookup latency in nanoseconds.
86    ///
87    /// Increments the miss counter and accumulates latency for average
88    /// latency calculation.
89    pub fn record_miss(&self, latency_ns: u64) {
90        self.misses.fetch_add(1, Ordering::Relaxed);
91        self.total_miss_latency_ns
92            .fetch_add(latency_ns, Ordering::Relaxed);
93        self.latency_samples.fetch_add(1, Ordering::Relaxed);
94    }
95
96    /// Record one cache eviction.
97    pub fn record_eviction(&self) {
98        self.evictions.fetch_add(1, Ordering::Relaxed);
99    }
100
101    /// Return the current hit rate as a fraction in `[0.0, 1.0]`.
102    ///
103    /// Returns `0.0` when no lookups have been recorded yet.
104    pub fn hit_rate(&self) -> f64 {
105        let h = self.hits.load(Ordering::Relaxed);
106        let m = self.misses.load(Ordering::Relaxed);
107        let total = h + m;
108        if total == 0 {
109            0.0
110        } else {
111            h as f64 / total as f64
112        }
113    }
114
115    /// Return the current miss rate as a fraction in `[0.0, 1.0]`.
116    ///
117    /// Returns `0.0` when no lookups have been recorded yet.
118    pub fn miss_rate(&self) -> f64 {
119        let h = self.hits.load(Ordering::Relaxed);
120        let m = self.misses.load(Ordering::Relaxed);
121        let total = h + m;
122        if total == 0 {
123            0.0
124        } else {
125            m as f64 / total as f64
126        }
127    }
128
129    /// Return the average lookup latency in nanoseconds across all recorded
130    /// operations (hits and misses combined).
131    ///
132    /// Returns `0.0` when no operations have been recorded.
133    pub fn avg_latency_ns(&self) -> f64 {
134        let samples = self.latency_samples.load(Ordering::Relaxed);
135        if samples == 0 {
136            return 0.0;
137        }
138        let total_lat = self.total_hit_latency_ns.load(Ordering::Relaxed)
139            + self.total_miss_latency_ns.load(Ordering::Relaxed);
140        total_lat as f64 / samples as f64
141    }
142
143    /// Return the total number of recorded hits.
144    pub fn total_hits(&self) -> u64 {
145        self.hits.load(Ordering::Relaxed)
146    }
147
148    /// Return the total number of recorded misses.
149    pub fn total_misses(&self) -> u64 {
150        self.misses.load(Ordering::Relaxed)
151    }
152
153    /// Return the total number of recorded evictions.
154    pub fn total_evictions(&self) -> u64 {
155        self.evictions.load(Ordering::Relaxed)
156    }
157
158    /// Return the eviction rate: `evictions / (hits + misses)`.
159    ///
160    /// Returns `0.0` when no lookups have been recorded yet.
161    pub fn eviction_rate(&self) -> f64 {
162        let h = self.hits.load(Ordering::Relaxed);
163        let m = self.misses.load(Ordering::Relaxed);
164        let evictions = self.evictions.load(Ordering::Relaxed);
165        let total = h + m;
166        if total == 0 {
167            0.0
168        } else {
169            evictions as f64 / total as f64
170        }
171    }
172
173    /// Reset all counters to zero.
174    pub fn reset(&self) {
175        self.hits.store(0, Ordering::Relaxed);
176        self.misses.store(0, Ordering::Relaxed);
177        self.evictions.store(0, Ordering::Relaxed);
178        self.total_hit_latency_ns.store(0, Ordering::Relaxed);
179        self.total_miss_latency_ns.store(0, Ordering::Relaxed);
180        self.latency_samples.store(0, Ordering::Relaxed);
181    }
182
183    /// Capture an immutable point-in-time snapshot of the current metrics.
184    pub fn snapshot(&self) -> CacheMetricsSnapshot {
185        let total_hits = self.hits.load(Ordering::Relaxed);
186        let total_misses = self.misses.load(Ordering::Relaxed);
187        let total_evictions = self.evictions.load(Ordering::Relaxed);
188        let total_lat = self.total_hit_latency_ns.load(Ordering::Relaxed)
189            + self.total_miss_latency_ns.load(Ordering::Relaxed);
190        let samples = self.latency_samples.load(Ordering::Relaxed);
191
192        let total_ops = total_hits + total_misses;
193        let hit_rate = if total_ops == 0 {
194            0.0
195        } else {
196            total_hits as f64 / total_ops as f64
197        };
198        let miss_rate = if total_ops == 0 {
199            0.0
200        } else {
201            total_misses as f64 / total_ops as f64
202        };
203        let avg_latency_ns = if samples == 0 {
204            0.0
205        } else {
206            total_lat as f64 / samples as f64
207        };
208        let eviction_rate = if total_ops == 0 {
209            0.0
210        } else {
211            total_evictions as f64 / total_ops as f64
212        };
213
214        CacheMetricsSnapshot {
215            hit_rate,
216            miss_rate,
217            total_hits,
218            total_misses,
219            total_evictions,
220            eviction_rate,
221            avg_latency_ns,
222        }
223    }
224}
225
226// ── CacheMetricsSnapshot ─────────────────────────────────────────────────────
227
228/// Immutable point-in-time snapshot of cache metrics.
229///
230/// Obtained by calling [`CacheMetrics::snapshot`].
231#[derive(Debug, Clone)]
232pub struct CacheMetricsSnapshot {
233    /// Fraction of lookups that resulted in a hit: `hits / (hits + misses)`.
234    pub hit_rate: f64,
235    /// Fraction of lookups that resulted in a miss: `misses / (hits + misses)`.
236    pub miss_rate: f64,
237    /// Total cache hits at snapshot time.
238    pub total_hits: u64,
239    /// Total cache misses at snapshot time.
240    pub total_misses: u64,
241    /// Total evictions at snapshot time.
242    pub total_evictions: u64,
243    /// Eviction rate: `evictions / (hits + misses)`.
244    pub eviction_rate: f64,
245    /// Average lookup latency in nanoseconds.
246    pub avg_latency_ns: f64,
247}
248
249impl CacheMetricsSnapshot {
250    /// Return `true` when the hit rate exceeds `threshold` (e.g. `0.80`).
251    pub fn is_hit_rate_above(&self, threshold: f64) -> bool {
252        self.hit_rate > threshold
253    }
254
255    /// Return the total number of operations (hits + misses).
256    pub fn total_ops(&self) -> u64 {
257        self.total_hits + self.total_misses
258    }
259}
260
261// ── Tests ─────────────────────────────────────────────────────────────────────
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use std::sync::Arc;
267    use std::thread;
268
269    // 1. New metrics start at zero
270    #[test]
271    fn test_new_metrics_zeroed() {
272        let m = CacheMetrics::new();
273        assert_eq!(m.total_hits(), 0);
274        assert_eq!(m.total_misses(), 0);
275        assert_eq!(m.total_evictions(), 0);
276        assert_eq!(m.hit_rate(), 0.0);
277        assert_eq!(m.miss_rate(), 0.0);
278        assert_eq!(m.avg_latency_ns(), 0.0);
279    }
280
281    // 2. record_hit increments hits
282    #[test]
283    fn test_record_hit() {
284        let m = CacheMetrics::new();
285        m.record_hit(100);
286        m.record_hit(200);
287        assert_eq!(m.total_hits(), 2);
288        assert_eq!(m.total_misses(), 0);
289    }
290
291    // 3. record_miss increments misses
292    #[test]
293    fn test_record_miss() {
294        let m = CacheMetrics::new();
295        m.record_miss(500);
296        assert_eq!(m.total_misses(), 1);
297        assert_eq!(m.total_hits(), 0);
298    }
299
300    // 4. record_eviction increments evictions
301    #[test]
302    fn test_record_eviction() {
303        let m = CacheMetrics::new();
304        m.record_eviction();
305        m.record_eviction();
306        m.record_eviction();
307        assert_eq!(m.total_evictions(), 3);
308    }
309
310    // 5. hit_rate with equal hits and misses
311    #[test]
312    fn test_hit_rate_equal() {
313        let m = CacheMetrics::new();
314        for _ in 0..50 {
315            m.record_hit(10);
316        }
317        for _ in 0..50 {
318            m.record_miss(10);
319        }
320        assert!((m.hit_rate() - 0.5).abs() < 1e-9);
321    }
322
323    // 6. miss_rate is complement of hit_rate
324    #[test]
325    fn test_miss_rate_complement() {
326        let m = CacheMetrics::new();
327        m.record_hit(10);
328        m.record_hit(10);
329        m.record_hit(10);
330        m.record_miss(10);
331        let hr = m.hit_rate();
332        let mr = m.miss_rate();
333        assert!((hr + mr - 1.0).abs() < 1e-9, "hit+miss should equal 1.0");
334    }
335
336    // 7. avg_latency_ns calculation
337    #[test]
338    fn test_avg_latency_ns() {
339        let m = CacheMetrics::new();
340        m.record_hit(100);
341        m.record_hit(300);
342        m.record_miss(200);
343        // (100 + 300 + 200) / 3 = 200.0
344        let avg = m.avg_latency_ns();
345        assert!((avg - 200.0).abs() < 1e-9, "expected 200ns avg, got {avg}");
346    }
347
348    // 8. snapshot captures consistent values
349    #[test]
350    fn test_snapshot_consistency() {
351        let m = CacheMetrics::new();
352        for i in 0u64..10 {
353            m.record_hit(i * 10);
354        }
355        for _ in 0..5 {
356            m.record_miss(50);
357        }
358        m.record_eviction();
359        let s = m.snapshot();
360        assert_eq!(s.total_hits, 10);
361        assert_eq!(s.total_misses, 5);
362        assert_eq!(s.total_evictions, 1);
363        assert!((s.hit_rate - 10.0 / 15.0).abs() < 1e-9);
364        assert!((s.miss_rate - 5.0 / 15.0).abs() < 1e-9);
365        assert!((s.hit_rate + s.miss_rate - 1.0).abs() < 1e-9);
366    }
367
368    // 9. eviction_rate calculation
369    #[test]
370    fn test_eviction_rate() {
371        let m = CacheMetrics::new();
372        m.record_hit(10);
373        m.record_miss(10);
374        m.record_eviction();
375        // 1 eviction / 2 ops = 0.5
376        assert!((m.eviction_rate() - 0.5).abs() < 1e-9);
377    }
378
379    // 10. reset clears all counters
380    #[test]
381    fn test_reset() {
382        let m = CacheMetrics::new();
383        m.record_hit(1000);
384        m.record_miss(2000);
385        m.record_eviction();
386        m.reset();
387        assert_eq!(m.total_hits(), 0);
388        assert_eq!(m.total_misses(), 0);
389        assert_eq!(m.total_evictions(), 0);
390        assert_eq!(m.avg_latency_ns(), 0.0);
391        assert_eq!(m.hit_rate(), 0.0);
392    }
393
394    // 11. is_hit_rate_above threshold check
395    #[test]
396    fn test_snapshot_is_hit_rate_above() {
397        let m = CacheMetrics::new();
398        for _ in 0..90 {
399            m.record_hit(10);
400        }
401        for _ in 0..10 {
402            m.record_miss(10);
403        }
404        let s = m.snapshot();
405        assert!(s.is_hit_rate_above(0.80));
406        assert!(!s.is_hit_rate_above(0.95));
407    }
408
409    // 12. snapshot total_ops helper
410    #[test]
411    fn test_snapshot_total_ops() {
412        let m = CacheMetrics::new();
413        m.record_hit(1);
414        m.record_hit(1);
415        m.record_miss(1);
416        let s = m.snapshot();
417        assert_eq!(s.total_ops(), 3);
418    }
419
420    // 13. Concurrent recording from multiple threads
421    #[test]
422    fn test_concurrent_recording() {
423        let m = Arc::new(CacheMetrics::new());
424        let threads: Vec<_> = (0..8)
425            .map(|_| {
426                let m2 = Arc::clone(&m);
427                thread::spawn(move || {
428                    for _ in 0..1000 {
429                        m2.record_hit(50);
430                        m2.record_miss(100);
431                        m2.record_eviction();
432                    }
433                })
434            })
435            .collect();
436        for t in threads {
437            t.join().expect("thread panicked");
438        }
439        assert_eq!(m.total_hits(), 8 * 1000);
440        assert_eq!(m.total_misses(), 8 * 1000);
441        assert_eq!(m.total_evictions(), 8 * 1000);
442    }
443
444    // 14. hit_rate and miss_rate are 0 before any ops
445    #[test]
446    fn test_zero_ops_rates() {
447        let m = CacheMetrics::new();
448        assert_eq!(m.hit_rate(), 0.0);
449        assert_eq!(m.miss_rate(), 0.0);
450        assert_eq!(m.eviction_rate(), 0.0);
451    }
452
453    // 15. new_shared creates Arc-wrapped metrics
454    #[test]
455    fn test_new_shared() {
456        let m = CacheMetrics::new_shared();
457        m.record_hit(1);
458        assert_eq!(m.total_hits(), 1);
459    }
460}