orinium_browser/engine/
cache_stats.rs1use std::fmt;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15#[derive(Debug)]
17pub struct CacheStats {
18 name: &'static str,
19 hits: AtomicU64,
20 misses: AtomicU64,
21 evictions: AtomicU64,
22 skips: AtomicU64,
23}
24
25#[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 pub skips: u64,
34}
35
36impl CacheStatsSnapshot {
37 pub fn accesses(&self) -> u64 {
39 self.hits + self.misses
40 }
41
42 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 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 pub fn record_skip(&self) {
102 self.skips.fetch_add(1, Ordering::Relaxed);
103 }
104
105 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}