Skip to main content

spg_engine/
query_stats.rs

1//! v6.5.1 — per-distinct-SQL LRU stat collector.
2//!
3//! Tracks `(exec_count, total_us, max_us, last_seen_us)` per unique
4//! SQL string. Bounded LRU cap of 1024 entries — when the cap is
5//! exceeded the least-recently-recorded entry is evicted. Engine
6//! calls `record(sql, elapsed_us, now_us)` after every successful
7//! execute; the virtual table `spg_stat_query` reads the entries.
8//!
9//! Honest scope: SPG's plan cache (v6.3.0) lives at a different
10//! layer — that one is keyed on SQL text too, but its purpose is
11//! AST reuse, not observability. The query-stats layer is purely
12//! introspection.
13
14use alloc::collections::{BTreeMap, VecDeque};
15use alloc::string::String;
16use alloc::vec::Vec;
17
18/// Cap on distinct queries tracked. PG's pg_stat_statements
19/// defaults to 5000; SPG ships 1024 because typical app workloads
20/// reuse far fewer distinct statements. Configurable in v6.5.6.
21pub(crate) const QUERY_STATS_MAX: usize = 1024;
22
23#[derive(Debug, Clone, Default)]
24pub struct QueryStat {
25    pub exec_count: u64,
26    pub total_us: u64,
27    pub max_us: u64,
28    pub last_seen_us: u64,
29}
30
31#[derive(Debug, Clone, Default)]
32pub struct QueryStats {
33    /// SQL string → stat counters. BTreeMap for deterministic
34    /// iteration (the `spg_stat_query` virtual table needs stable
35    /// row order across reads).
36    entries: BTreeMap<String, QueryStat>,
37    /// LRU order. Most-recently-recorded at the back. `record`
38    /// touches this; `evict` pops the front.
39    lru: VecDeque<String>,
40}
41
42impl QueryStats {
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    pub fn len(&self) -> usize {
48        self.entries.len()
49    }
50
51    pub fn is_empty(&self) -> bool {
52        self.entries.is_empty()
53    }
54
55    /// Returns the recorded stat snapshot, if any. Does NOT promote
56    /// LRU (introspection should be side-effect free).
57    pub fn get(&self, sql: &str) -> Option<&QueryStat> {
58        self.entries.get(sql)
59    }
60
61    /// Iterate every recorded entry in deterministic (BTreeMap)
62    /// order. Used by `spg_stat_query` virtual table.
63    pub fn iter(&self) -> impl Iterator<Item = (&String, &QueryStat)> {
64        self.entries.iter()
65    }
66
67    /// Record one execution. `elapsed_us` is the wall-clock micros
68    /// between start and end; `now_us` is the wall-clock micros at
69    /// completion (used for `last_seen_us`).
70    pub fn record(&mut self, sql: &str, elapsed_us: u64, now_us: u64) {
71        if let Some(stat) = self.entries.get_mut(sql) {
72            stat.exec_count = stat.exec_count.saturating_add(1);
73            stat.total_us = stat.total_us.saturating_add(elapsed_us);
74            stat.max_us = stat.max_us.max(elapsed_us);
75            stat.last_seen_us = now_us;
76            // Promote to MRU in lru queue.
77            if let Some(idx) = self.lru.iter().position(|k| k == sql) {
78                let key = self.lru.remove(idx).expect("idx from position");
79                self.lru.push_back(key);
80            }
81            return;
82        }
83        // New entry: enforce cap.
84        if self.entries.len() >= QUERY_STATS_MAX
85            && let Some(oldest) = self.lru.pop_front()
86        {
87            self.entries.remove(&oldest);
88        }
89        self.entries.insert(
90            String::from(sql),
91            QueryStat {
92                exec_count: 1,
93                total_us: elapsed_us,
94                max_us: elapsed_us,
95                last_seen_us: now_us,
96            },
97        );
98        self.lru.push_back(String::from(sql));
99    }
100
101    /// v6.5.6 — operator-controlled clear (e.g. for ops resets).
102    pub fn clear(&mut self) {
103        self.entries.clear();
104        self.lru.clear();
105    }
106
107    pub fn cap(&self) -> usize {
108        QUERY_STATS_MAX
109    }
110
111    /// Snapshot rows in LRU order (oldest → newest). Used by
112    /// `spg_stat_query` ORDER BY default.
113    pub fn snapshot(&self) -> Vec<(String, QueryStat)> {
114        self.lru
115            .iter()
116            .filter_map(|sql| self.entries.get(sql).map(|s| (sql.clone(), s.clone())))
117            .collect()
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use alloc::string::ToString;
125
126    #[test]
127    fn record_increments_counters() {
128        let mut qs = QueryStats::new();
129        qs.record("SELECT 1", 100, 1000);
130        qs.record("SELECT 1", 200, 2000);
131        let s = qs.get("SELECT 1").expect("present");
132        assert_eq!(s.exec_count, 2);
133        assert_eq!(s.total_us, 300);
134        assert_eq!(s.max_us, 200);
135        assert_eq!(s.last_seen_us, 2000);
136    }
137
138    #[test]
139    fn distinct_sql_yields_separate_entries() {
140        let mut qs = QueryStats::new();
141        qs.record("SELECT 1", 10, 100);
142        qs.record("SELECT 2", 20, 200);
143        assert_eq!(qs.len(), 2);
144    }
145
146    #[test]
147    fn lru_evicts_oldest_at_cap() {
148        let mut qs = QueryStats::new();
149        for i in 0..QUERY_STATS_MAX {
150            qs.record(&alloc::format!("SELECT {i}"), 1, i as u64);
151        }
152        assert_eq!(qs.len(), QUERY_STATS_MAX);
153        // Trigger cap.
154        qs.record("SELECT new", 1, QUERY_STATS_MAX as u64);
155        assert_eq!(qs.len(), QUERY_STATS_MAX);
156        assert!(qs.get("SELECT 0").is_none(), "oldest evicted");
157        assert!(qs.get("SELECT new").is_some());
158    }
159
160    #[test]
161    fn re_recording_an_entry_promotes_lru() {
162        let mut qs = QueryStats::new();
163        qs.record("a", 1, 1);
164        qs.record("b", 1, 2);
165        qs.record("c", 1, 3);
166        // Touch "a" — should become MRU.
167        qs.record("a", 1, 4);
168        // Fill to cap so the next insert evicts the LRU front.
169        for i in 0..(QUERY_STATS_MAX - 3) {
170            qs.record(&alloc::format!("filler{i}"), 1, 100 + i as u64);
171        }
172        qs.record("trigger", 1, 9999);
173        assert!(qs.get("a").is_some(), "a was MRU; should survive");
174        assert!(qs.get("b").is_none(), "b should be evicted");
175    }
176
177    #[test]
178    fn clear_drops_everything() {
179        let mut qs = QueryStats::new();
180        qs.record("a", 1, 1);
181        qs.record("b", 1, 2);
182        qs.clear();
183        assert!(qs.is_empty());
184    }
185
186    #[test]
187    fn snapshot_returns_lru_order_oldest_first() {
188        let mut qs = QueryStats::new();
189        qs.record("a", 1, 100);
190        qs.record("b", 1, 200);
191        qs.record("c", 1, 300);
192        let snap = qs.snapshot();
193        let keys: Vec<String> = snap.iter().map(|(k, _)| k.clone()).collect();
194        assert_eq!(
195            keys,
196            alloc::vec!["a".to_string(), "b".to_string(), "c".to_string()]
197        );
198    }
199}