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.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}