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