Skip to main content

shiplog_cache/
stats.rs

1//! Canonical cache-stat normalization contracts for shiplog caches.
2//!
3//! This crate has one responsibility:
4//! converting raw storage counts into stable, non-negative cache stats.
5
6/// Byte size used to compute whole-megabyte cache reports.
7pub const BYTES_PER_MEGABYTE: u64 = 1024 * 1024;
8
9/// Cache statistics exposed by shiplog caches.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct CacheStats {
12    pub total_entries: usize,
13    pub expired_entries: usize,
14    pub valid_entries: usize,
15    pub cache_size_mb: u64,
16}
17
18impl CacheStats {
19    /// Build canonical stats from raw storage values.
20    ///
21    /// Rules:
22    /// - negative values clamp to zero
23    /// - expired entries clamp to total entries
24    /// - valid entries are always `total - expired`
25    /// - cache size is reported as floor(bytes / MiB)
26    #[must_use]
27    pub fn from_raw_counts(
28        total_entries: i64,
29        expired_entries: i64,
30        cache_size_bytes: i64,
31    ) -> Self {
32        let total_raw = total_entries.max(0) as u64;
33        let expired_raw = expired_entries.max(0) as u64;
34        let clamped_expired_raw = expired_raw.min(total_raw);
35
36        let total_entries = clamp_u64_to_usize(total_raw);
37        let expired_entries = clamp_u64_to_usize(clamped_expired_raw).min(total_entries);
38        let valid_entries = total_entries.saturating_sub(expired_entries);
39        let cache_size_mb = (cache_size_bytes.max(0) as u64) / BYTES_PER_MEGABYTE;
40
41        Self {
42            total_entries,
43            expired_entries,
44            valid_entries,
45            cache_size_mb,
46        }
47    }
48
49    /// True when the cache has no entries.
50    #[must_use]
51    pub fn is_empty(&self) -> bool {
52        self.total_entries == 0
53    }
54}
55
56fn clamp_u64_to_usize(value: u64) -> usize {
57    let max = usize::MAX as u64;
58    value.min(max) as usize
59}
60
61#[cfg(test)]
62mod tests {
63    use super::{BYTES_PER_MEGABYTE, CacheStats};
64
65    #[test]
66    fn from_raw_counts_maps_normal_values() {
67        let stats = CacheStats::from_raw_counts(9, 2, 5_242_880);
68        assert_eq!(stats.total_entries, 9);
69        assert_eq!(stats.expired_entries, 2);
70        assert_eq!(stats.valid_entries, 7);
71        assert_eq!(stats.cache_size_mb, 5);
72    }
73
74    #[test]
75    fn from_raw_counts_clamps_negative_values() {
76        let stats = CacheStats::from_raw_counts(-10, -3, -99);
77        assert_eq!(stats.total_entries, 0);
78        assert_eq!(stats.expired_entries, 0);
79        assert_eq!(stats.valid_entries, 0);
80        assert_eq!(stats.cache_size_mb, 0);
81    }
82
83    #[test]
84    fn from_raw_counts_clamps_expired_entries_to_total() {
85        let stats = CacheStats::from_raw_counts(3, 99, 0);
86        assert_eq!(stats.total_entries, 3);
87        assert_eq!(stats.expired_entries, 3);
88        assert_eq!(stats.valid_entries, 0);
89    }
90
91    #[test]
92    fn from_raw_counts_rounds_down_megabytes() {
93        let stats = CacheStats::from_raw_counts(1, 0, (BYTES_PER_MEGABYTE * 2 + 123) as i64);
94        assert_eq!(stats.cache_size_mb, 2);
95    }
96
97    #[test]
98    fn is_empty_is_driven_by_total_entries() {
99        assert!(CacheStats::from_raw_counts(0, 0, 0).is_empty());
100        assert!(!CacheStats::from_raw_counts(1, 0, 0).is_empty());
101    }
102}