Skip to main content

orinium_browser/engine/
cache_stats.rs

1//! Hit/miss counters shared by the engine and platform caches.
2//!
3//! Every cache in the engine (CSS selector match results) and in the platform
4//! layer (HTTP responses, glyph atlas, glyph bearings, text layouts, text
5//! shaping) owns a [`CacheStats`] counter set so hit rates and eviction
6//! pressure can be observed at runtime and measured in `benches/cache_bench.rs`.
7//!
8//! This module lives in the engine layer so engine-side caches can count hits
9//! without reaching down into the platform; the platform re-exports it under
10//! its original path.
11
12use std::fmt;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15/// Monotonic counters describing how a cache behaves.
16#[derive(Debug)]
17pub struct CacheStats {
18    name: &'static str,
19    hits: AtomicU64,
20    misses: AtomicU64,
21    evictions: AtomicU64,
22    skips: AtomicU64,
23}
24
25/// Immutable view over a [`CacheStats`] counter set.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct CacheStatsSnapshot {
28    pub name: &'static str,
29    pub hits: u64,
30    pub misses: u64,
31    pub evictions: u64,
32    /// Insertions rejected because a cache with admission limits was full.
33    pub skips: u64,
34}
35
36impl CacheStatsSnapshot {
37    /// Number of lookups that hit or missed.
38    pub fn accesses(&self) -> u64 {
39        self.hits + self.misses
40    }
41
42    /// Fraction of lookups that hit; `0.0` when nothing was looked up yet.
43    pub fn hit_rate(&self) -> f64 {
44        let accesses = self.accesses();
45        if accesses == 0 {
46            0.0
47        } else {
48            self.hits as f64 / accesses as f64
49        }
50    }
51}
52
53impl fmt::Display for CacheStatsSnapshot {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        write!(
56            f,
57            "{}: {} hits / {} misses ({:.1}%), {} evictions",
58            self.name,
59            self.hits,
60            self.misses,
61            self.hit_rate() * 100.0,
62            self.evictions
63        )?;
64        if self.skips > 0 {
65            write!(f, ", {} skips", self.skips)?;
66        }
67        Ok(())
68    }
69}
70
71impl CacheStats {
72    /// Creates a counter set labelled with `name` for log output.
73    pub const fn new(name: &'static str) -> Self {
74        Self {
75            name,
76            hits: AtomicU64::new(0),
77            misses: AtomicU64::new(0),
78            evictions: AtomicU64::new(0),
79            skips: AtomicU64::new(0),
80        }
81    }
82
83    pub fn name(&self) -> &'static str {
84        self.name
85    }
86
87    pub fn record_hit(&self) {
88        self.hits.fetch_add(1, Ordering::Relaxed);
89    }
90
91    pub fn record_miss(&self) {
92        self.misses.fetch_add(1, Ordering::Relaxed);
93    }
94
95    pub fn record_eviction(&self) {
96        self.evictions.fetch_add(1, Ordering::Relaxed);
97    }
98
99    /// Records an insertion that was rejected because an admission-limited
100    /// cache was already full.
101    pub fn record_skip(&self) {
102        self.skips.fetch_add(1, Ordering::Relaxed);
103    }
104
105    /// Resets every counter to zero, for caches whose statistics describe one
106    /// invalidation epoch rather than the whole process lifetime.
107    pub fn reset(&self) {
108        self.hits.store(0, Ordering::Relaxed);
109        self.misses.store(0, Ordering::Relaxed);
110        self.evictions.store(0, Ordering::Relaxed);
111        self.skips.store(0, Ordering::Relaxed);
112    }
113
114    pub fn snapshot(&self) -> CacheStatsSnapshot {
115        CacheStatsSnapshot {
116            name: self.name,
117            hits: self.hits.load(Ordering::Relaxed),
118            misses: self.misses.load(Ordering::Relaxed),
119            evictions: self.evictions.load(Ordering::Relaxed),
120            skips: self.skips.load(Ordering::Relaxed),
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn counters_accumulate_and_compose_a_hit_rate() {
131        let stats = CacheStats::new("test");
132
133        assert_eq!(stats.snapshot().accesses(), 0);
134        assert_eq!(stats.snapshot().hit_rate(), 0.0);
135
136        stats.record_hit();
137        stats.record_hit();
138        stats.record_miss();
139        stats.record_eviction();
140        stats.record_skip();
141
142        let snapshot = stats.snapshot();
143        assert_eq!(snapshot.name, "test");
144        assert_eq!(snapshot.hits, 2);
145        assert_eq!(snapshot.misses, 1);
146        assert_eq!(snapshot.evictions, 1);
147        assert_eq!(snapshot.skips, 1);
148        assert_eq!(snapshot.accesses(), 3);
149        assert!((snapshot.hit_rate() - 2.0 / 3.0).abs() < f64::EPSILON);
150    }
151
152    #[test]
153    fn reset_zeroes_every_counter() {
154        let stats = CacheStats::new("test");
155        stats.record_hit();
156        stats.record_miss();
157        stats.record_eviction();
158        stats.record_skip();
159
160        stats.reset();
161
162        let snapshot = stats.snapshot();
163        assert_eq!(snapshot.accesses(), 0);
164        assert_eq!(snapshot.evictions, 0);
165        assert_eq!(snapshot.skips, 0);
166    }
167
168    #[test]
169    fn display_includes_the_label_and_rates() {
170        let stats = CacheStats::new("glyph_atlas");
171        stats.record_hit();
172        stats.record_miss();
173
174        assert_eq!(
175            stats.snapshot().to_string(),
176            "glyph_atlas: 1 hits / 1 misses (50.0%), 0 evictions"
177        );
178    }
179
180    #[test]
181    fn display_includes_skips_only_when_present() {
182        let stats = CacheStats::new("selector_match");
183        stats.record_miss();
184        stats.record_skip();
185
186        assert_eq!(
187            stats.snapshot().to_string(),
188            "selector_match: 0 hits / 1 misses (0.0%), 0 evictions, 1 skips"
189        );
190    }
191}