Skip to main content

rustlavel_model_cache/
stats.rs

1//! What the cache has been doing.
2//!
3//! Kept because the first question anybody asks of a cache is "is it working",
4//! and the honest answer needs a hit rate rather than an impression. Counted
5//! per model, since one table doing badly is invisible in a total.
6
7use std::collections::BTreeMap;
8use std::sync::Mutex;
9use std::sync::atomic::{AtomicU64, Ordering};
10
11/// One region's tally.
12#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
13pub struct Counts {
14    pub entity_hits: u64,
15    pub entity_misses: u64,
16    pub query_hits: u64,
17    pub query_misses: u64,
18    /// Result sets not stored because they were over `max_rows`.
19    pub too_large: u64,
20    /// Result sets not stored because a column cannot round-trip through JSON
21    /// — a binary column. Worth watching: a table that never caches is a
22    /// region that is doing nothing.
23    pub unsupported: u64,
24    /// Generation bumps: how often a write threw this table's queries away.
25    pub invalidations: u64,
26}
27
28impl Counts {
29    /// Hits as a fraction of lookups, entities and queries together.
30    ///
31    /// `None` when nothing has been looked up: a cache nobody has asked
32    /// anything of has no hit rate, and reporting 0% would read as a problem.
33    pub fn hit_rate(&self) -> Option<f64> {
34        let hits = self.entity_hits + self.query_hits;
35        let total = hits + self.entity_misses + self.query_misses;
36        (total > 0).then(|| hits as f64 / total as f64)
37    }
38}
39
40#[derive(Default)]
41struct Tally {
42    entity_hits: AtomicU64,
43    entity_misses: AtomicU64,
44    query_hits: AtomicU64,
45    query_misses: AtomicU64,
46    too_large: AtomicU64,
47    unsupported: AtomicU64,
48    invalidations: AtomicU64,
49}
50
51impl Tally {
52    fn read(&self) -> Counts {
53        Counts {
54            entity_hits: self.entity_hits.load(Ordering::Relaxed),
55            entity_misses: self.entity_misses.load(Ordering::Relaxed),
56            query_hits: self.query_hits.load(Ordering::Relaxed),
57            query_misses: self.query_misses.load(Ordering::Relaxed),
58            too_large: self.too_large.load(Ordering::Relaxed),
59            unsupported: self.unsupported.load(Ordering::Relaxed),
60            invalidations: self.invalidations.load(Ordering::Relaxed),
61        }
62    }
63}
64
65/// The counters, by model name.
66#[derive(Default)]
67pub struct Stats {
68    tables: Mutex<BTreeMap<&'static str, Tally>>,
69}
70
71/// Which counter a call touched.
72#[derive(Debug, Clone, Copy)]
73pub enum Event {
74    EntityHit,
75    EntityMiss,
76    QueryHit,
77    QueryMiss,
78    TooLarge,
79    Unsupported,
80    Invalidated,
81}
82
83impl Stats {
84    pub fn record(&self, table: &'static str, event: Event) {
85        // `Relaxed` throughout: these are counters nobody makes a decision on,
86        // and ordering them against each other would cost more than the
87        // numbers are worth.
88        let mut tables = self.tables.lock().unwrap_or_else(|e| e.into_inner());
89        let tally = tables.entry(table).or_default();
90        let counter = match event {
91            Event::EntityHit => &tally.entity_hits,
92            Event::EntityMiss => &tally.entity_misses,
93            Event::QueryHit => &tally.query_hits,
94            Event::QueryMiss => &tally.query_misses,
95            Event::TooLarge => &tally.too_large,
96            Event::Unsupported => &tally.unsupported,
97            Event::Invalidated => &tally.invalidations,
98        };
99        counter.fetch_add(1, Ordering::Relaxed);
100    }
101
102    /// Every region's tally, by model name.
103    pub fn all(&self) -> BTreeMap<&'static str, Counts> {
104        let tables = self.tables.lock().unwrap_or_else(|e| e.into_inner());
105        tables.iter().map(|(name, tally)| (*name, tally.read())).collect()
106    }
107
108    pub fn for_table(&self, table: &str) -> Counts {
109        let tables = self.tables.lock().unwrap_or_else(|e| e.into_inner());
110        tables.get(table).map(Tally::read).unwrap_or_default()
111    }
112
113    /// The totals across every region.
114    pub fn total(&self) -> Counts {
115        self.all().values().fold(Counts::default(), |mut sum, counts| {
116            sum.entity_hits += counts.entity_hits;
117            sum.entity_misses += counts.entity_misses;
118            sum.query_hits += counts.query_hits;
119            sum.query_misses += counts.query_misses;
120            sum.too_large += counts.too_large;
121            sum.unsupported += counts.unsupported;
122            sum.invalidations += counts.invalidations;
123            sum
124        })
125    }
126
127    pub fn reset(&self) {
128        self.tables.lock().unwrap_or_else(|e| e.into_inner()).clear();
129    }
130}
131
132impl std::fmt::Debug for Stats {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.debug_struct("Stats").field("tables", &self.all()).finish()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn a_cache_nobody_has_asked_anything_of_has_no_hit_rate() {
144        assert_eq!(Counts::default().hit_rate(), None);
145
146        let counts = Counts { entity_hits: 3, entity_misses: 1, ..Default::default() };
147        assert_eq!(counts.hit_rate(), Some(0.75));
148    }
149
150    #[test]
151    fn counters_are_kept_per_table_and_summed_on_request() {
152        let stats = Stats::default();
153        stats.record("users", Event::EntityHit);
154        stats.record("users", Event::EntityHit);
155        stats.record("users", Event::QueryMiss);
156        stats.record("posts", Event::EntityMiss);
157
158        assert_eq!(stats.for_table("users").entity_hits, 2);
159        assert_eq!(stats.for_table("posts").entity_misses, 1);
160        assert_eq!(stats.for_table("nothing"), Counts::default());
161
162        let total = stats.total();
163        assert_eq!(total.entity_hits, 2);
164        assert_eq!(total.entity_misses, 1);
165        assert_eq!(total.query_misses, 1);
166
167        stats.reset();
168        assert_eq!(stats.total(), Counts::default());
169    }
170}