Skip to main content

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}