hydracache_core/stats.rs
1/// Snapshot of lightweight cache counters.
2///
3/// The counters are intentionally lightweight and approximate enough for local
4/// observability. They are not intended to be a durable metrics store.
5///
6/// # Example
7///
8/// ```rust
9/// use hydracache_core::CacheStats;
10///
11/// let stats = CacheStats::default();
12/// assert_eq!(stats.hits, 0);
13/// assert_eq!(stats.single_flight_joins, 0);
14/// assert_eq!(stats.load_breaker_open_total, 0);
15/// assert_eq!(stats.oversize_rejections, 0);
16/// assert_eq!(stats.consistency_wait_successes, 0);
17/// assert_eq!(stats.events_published, 0);
18/// assert_eq!(stats.distributed_invalidations_published, 0);
19/// assert_eq!(stats.distributed_invalidation_lagged, 0);
20/// assert_eq!(stats.distributed_invalidation_decode_errors, 0);
21/// assert_eq!(stats.total_requests(), 0);
22/// assert_eq!(stats.hit_ratio(), None);
23/// ```
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct CacheStats {
26 /// Successful cache lookups.
27 pub hits: u64,
28 /// Cache lookups that did not return a usable value.
29 pub misses: u64,
30 /// Loader closures executed by `get_or_load`.
31 pub loads: u64,
32 /// Calls that joined an already running single-flight load.
33 pub single_flight_joins: u64,
34 /// Loader results skipped because their invalidation generation became stale.
35 pub stale_load_discards: u64,
36 /// Per-key loader breakers opened after repeated failures.
37 pub load_breaker_open_total: u64,
38 /// Per-key loader breakers allowed one half-open probe.
39 pub load_breaker_half_open_total: u64,
40 /// Per-key loader breakers closed after a successful probe.
41 pub load_breaker_recovered_total: u64,
42 /// Loader calls rejected before invoking the loader because a breaker was open.
43 pub load_breaker_rejected_total: u64,
44 /// Entries removed by invalidation APIs.
45 pub invalidations: u64,
46 /// Entries observed as evicted by the backend.
47 ///
48 /// v0 does not wire backend eviction listeners yet, so this remains zero.
49 pub evictions: u64,
50 /// Entries rejected before insertion because encoded bytes exceeded
51 /// `max_entry_bytes`.
52 pub oversize_rejections: u64,
53 /// Cache events delivered to at least one subscriber.
54 pub events_published: u64,
55 /// Event notifications skipped by slow subscribers.
56 pub event_subscriber_lagged: u64,
57 /// Invalidation messages published to an attached bus.
58 pub distributed_invalidations_published: u64,
59 /// Invalidation messages received from an attached bus.
60 pub distributed_invalidations_received: u64,
61 /// Received invalidation messages applied to the local cache.
62 pub distributed_invalidations_applied: u64,
63 /// Invalidation messages skipped because a bus receiver lagged behind.
64 pub distributed_invalidation_lagged: u64,
65 /// Invalidation transport frames that could not be decoded.
66 pub distributed_invalidation_decode_errors: u64,
67 /// Invalidation publish attempts that returned an error.
68 pub distributed_invalidation_publish_failures: u64,
69 /// Times an attached bus receiver reported that the stream closed.
70 pub distributed_invalidation_receiver_closed: u64,
71 /// Consistency-token waits that observed the requested generation.
72 pub consistency_wait_successes: u64,
73 /// Consistency-token waits that timed out.
74 pub consistency_wait_timeouts: u64,
75 /// Consistency-token reads that returned a degraded value.
76 pub consistency_degraded_reads: u64,
77 /// Consistency-token reads that failed closed instead of serving stale data.
78 pub consistency_fail_closed: u64,
79}
80
81impl CacheStats {
82 /// Return the number of lookup attempts represented by this snapshot.
83 ///
84 /// This is `hits + misses`, so it intentionally does not include loader
85 /// executions, invalidations, or backend evictions.
86 ///
87 /// # Example
88 ///
89 /// ```rust
90 /// use hydracache_core::CacheStats;
91 ///
92 /// let stats = CacheStats {
93 /// hits: 3,
94 /// misses: 1,
95 /// ..CacheStats::default()
96 /// };
97 ///
98 /// assert_eq!(stats.total_requests(), 4);
99 /// ```
100 pub fn total_requests(&self) -> u64 {
101 self.hits + self.misses
102 }
103
104 /// Return the cache hit ratio for this snapshot.
105 ///
106 /// Returns `None` when no lookup has happened yet. Otherwise the value is
107 /// `hits / (hits + misses)` in the `0.0..=1.0` range.
108 ///
109 /// # Example
110 ///
111 /// ```rust
112 /// use hydracache_core::CacheStats;
113 ///
114 /// let stats = CacheStats {
115 /// hits: 3,
116 /// misses: 1,
117 /// ..CacheStats::default()
118 /// };
119 ///
120 /// assert_eq!(stats.hit_ratio(), Some(0.75));
121 /// ```
122 pub fn hit_ratio(&self) -> Option<f64> {
123 let total = self.total_requests();
124 if total == 0 {
125 None
126 } else {
127 Some(self.hits as f64 / total as f64)
128 }
129 }
130
131 /// Return whether at least one caller joined an existing single-flight load.
132 ///
133 /// This is a compact way to check that concurrent misses were deduplicated.
134 pub fn has_single_flight_activity(&self) -> bool {
135 self.single_flight_joins > 0
136 }
137
138 /// Return whether a stale loader result was discarded after invalidation.
139 pub fn has_stale_load_discards(&self) -> bool {
140 self.stale_load_discards > 0
141 }
142
143 /// Return whether loader circuit-breaker activity was observed.
144 pub fn has_load_breaker_activity(&self) -> bool {
145 self.load_breaker_open_total > 0
146 || self.load_breaker_half_open_total > 0
147 || self.load_breaker_recovered_total > 0
148 || self.load_breaker_rejected_total > 0
149 }
150
151 /// Return whether at least one encoded value was rejected before insertion.
152 pub fn has_oversize_rejections(&self) -> bool {
153 self.oversize_rejections > 0
154 }
155
156 /// Return whether at least one event subscriber lagged behind the event bus.
157 pub fn has_event_subscriber_lag(&self) -> bool {
158 self.event_subscriber_lagged > 0
159 }
160
161 /// Return whether this cache has published or received bus invalidations.
162 pub fn has_distributed_invalidation_activity(&self) -> bool {
163 self.distributed_invalidations_published > 0
164 || self.distributed_invalidations_received > 0
165 || self.distributed_invalidations_applied > 0
166 || self.distributed_invalidation_lagged > 0
167 || self.distributed_invalidation_decode_errors > 0
168 || self.distributed_invalidation_publish_failures > 0
169 || self.distributed_invalidation_receiver_closed > 0
170 }
171
172 /// Return whether this cache observed invalidation bus health issues.
173 pub fn has_distributed_invalidation_bus_issues(&self) -> bool {
174 self.distributed_invalidation_lagged > 0
175 || self.distributed_invalidation_decode_errors > 0
176 || self.distributed_invalidation_publish_failures > 0
177 || self.distributed_invalidation_receiver_closed > 0
178 }
179}
180
181/// User-facing diagnostic snapshot for a local cache instance.
182///
183/// `CacheDiagnostics` combines lightweight counters with runtime-level
184/// observations such as the approximate number of entries currently known to
185/// the local backend. The values are snapshots, not a durable metrics store.
186///
187/// # Example
188///
189/// ```rust
190/// use hydracache_core::{CacheDiagnostics, CacheStats};
191///
192/// let diagnostics = CacheDiagnostics {
193/// stats: CacheStats {
194/// hits: 1,
195/// misses: 1,
196/// ..CacheStats::default()
197/// },
198/// estimated_entries: 1,
199/// };
200///
201/// assert_eq!(diagnostics.total_requests(), 2);
202/// assert_eq!(diagnostics.hit_ratio(), Some(0.5));
203/// assert!(!diagnostics.is_empty());
204/// ```
205#[derive(Debug, Clone, Copy, Default, PartialEq)]
206pub struct CacheDiagnostics {
207 /// Lightweight cache counters.
208 pub stats: CacheStats,
209 /// Approximate number of entries currently held by the local backend.
210 ///
211 /// This value comes from the in-memory backend and is meant for debugging
212 /// and smoke checks, not billing, quotas, or exact accounting.
213 pub estimated_entries: u64,
214}
215
216impl CacheDiagnostics {
217 /// Return the number of lookup attempts represented by this snapshot.
218 pub fn total_requests(&self) -> u64 {
219 self.stats.total_requests()
220 }
221
222 /// Return the hit ratio represented by this snapshot.
223 pub fn hit_ratio(&self) -> Option<f64> {
224 self.stats.hit_ratio()
225 }
226
227 /// Return whether the local backend currently appears empty.
228 pub fn is_empty(&self) -> bool {
229 self.estimated_entries == 0
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::{CacheDiagnostics, CacheStats};
236
237 #[test]
238 fn stats_helpers_cover_empty_and_non_empty_snapshots() {
239 let empty = CacheStats::default();
240 assert_eq!(empty.total_requests(), 0);
241 assert_eq!(empty.hit_ratio(), None);
242 assert!(!empty.has_single_flight_activity());
243 assert!(!empty.has_stale_load_discards());
244 assert!(!empty.has_load_breaker_activity());
245 assert!(!empty.has_event_subscriber_lag());
246 assert!(!empty.has_distributed_invalidation_activity());
247 assert!(!empty.has_distributed_invalidation_bus_issues());
248
249 let active = CacheStats {
250 hits: 3,
251 misses: 1,
252 single_flight_joins: 2,
253 stale_load_discards: 1,
254 load_breaker_open_total: 1,
255 load_breaker_half_open_total: 1,
256 load_breaker_recovered_total: 1,
257 load_breaker_rejected_total: 1,
258 oversize_rejections: 1,
259 event_subscriber_lagged: 1,
260 distributed_invalidations_published: 1,
261 distributed_invalidations_received: 1,
262 distributed_invalidations_applied: 1,
263 distributed_invalidation_lagged: 1,
264 distributed_invalidation_decode_errors: 1,
265 distributed_invalidation_publish_failures: 1,
266 distributed_invalidation_receiver_closed: 1,
267 ..CacheStats::default()
268 };
269 assert_eq!(active.total_requests(), 4);
270 assert_eq!(active.hit_ratio(), Some(0.75));
271 assert!(active.has_single_flight_activity());
272 assert!(active.has_stale_load_discards());
273 assert!(active.has_load_breaker_activity());
274 assert!(active.has_oversize_rejections());
275 assert!(active.has_event_subscriber_lag());
276 assert!(active.has_distributed_invalidation_activity());
277 assert!(active.has_distributed_invalidation_bus_issues());
278 }
279
280 #[test]
281 fn diagnostics_helpers_delegate_to_stats() {
282 let diagnostics = CacheDiagnostics {
283 stats: CacheStats {
284 hits: 1,
285 misses: 1,
286 ..CacheStats::default()
287 },
288 estimated_entries: 1,
289 };
290
291 assert_eq!(diagnostics.total_requests(), 2);
292 assert_eq!(diagnostics.hit_ratio(), Some(0.5));
293 assert!(!diagnostics.is_empty());
294
295 let empty = CacheDiagnostics::default();
296 assert_eq!(empty.hit_ratio(), None);
297 assert!(empty.is_empty());
298 }
299}