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    /// v7.37.22 (22.9) — cumulative row count produced / affected
30    /// across every execution of this normalised template. Matches
31    /// PG `pg_stat_statements.rows`. SELECT counts the result row
32    /// count; INSERT/UPDATE/DELETE count `affected`. Saturating add.
33    pub total_rows: u64,
34    /// v7.37.22 (22.9) — peak per-execution row count. Useful for
35    /// dashboards flagging templates whose worst case is a runaway
36    /// scan (e.g. a SELECT that usually returns 10 rows but
37    /// occasionally pulls 10M).
38    pub max_rows: u64,
39}
40
41#[derive(Debug, Clone, Default)]
42pub struct QueryStats {
43    /// SQL string → stat counters. BTreeMap for deterministic
44    /// iteration (the `spg_stat_query` virtual table needs stable
45    /// row order across reads).
46    entries: BTreeMap<String, QueryStat>,
47    /// LRU order. Most-recently-recorded at the back. `record`
48    /// touches this; `evict` pops the front.
49    lru: VecDeque<String>,
50}
51
52impl QueryStats {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    pub fn len(&self) -> usize {
58        self.entries.len()
59    }
60
61    pub fn is_empty(&self) -> bool {
62        self.entries.is_empty()
63    }
64
65    /// Returns the recorded stat snapshot, if any. Does NOT promote
66    /// LRU (introspection should be side-effect free).
67    ///
68    /// v7.37.22 (22.6) — `sql` is normalised before lookup so
69    /// callers don't have to know about the normalisation rules.
70    pub fn get(&self, sql: &str) -> Option<&QueryStat> {
71        let key = Self::normalize_sql(sql);
72        self.entries.get(&key)
73    }
74
75    /// Iterate every recorded entry in deterministic (BTreeMap)
76    /// order. Used by `spg_stat_query` virtual table.
77    pub fn iter(&self) -> impl Iterator<Item = (&String, &QueryStat)> {
78        self.entries.iter()
79    }
80
81    /// v7.37.22 (22.6) — normalise a SQL string for pg_stat_statements
82    /// grouping. Replaces literal values with `$N` placeholders so
83    /// `SELECT * FROM t WHERE id = 1` and `SELECT * FROM t WHERE id
84    /// = 2` collapse into the same key (matching PG's behaviour).
85    ///
86    /// Rules:
87    /// - Numeric literals (integer + float) → `$N`
88    /// - Single-quoted string literals (with `''` escape) → `$N`
89    /// - NULL / TRUE / FALSE → preserved (PG also preserves these)
90    /// - Whitespace runs → single space
91    /// - Comments stripped (`-- …` to EOL; `/* … */` block)
92    ///
93    /// Each replaced literal increments `N` so multi-literal
94    /// queries get `$1, $2, $3`. Round-trippable enough that DBAs
95    /// reading the normalised form can map it back to the original
96    /// query template.
97    pub fn normalize_sql(sql: &str) -> String {
98        let mut out = String::with_capacity(sql.len());
99        let bytes = sql.as_bytes();
100        let mut i = 0usize;
101        let mut param_counter: u32 = 0;
102        let mut last_was_space = true; // suppress leading space
103        while i < bytes.len() {
104            let b = bytes[i];
105            // Line comment.
106            if b == b'-' && i + 1 < bytes.len() && bytes[i + 1] == b'-' {
107                while i < bytes.len() && bytes[i] != b'\n' {
108                    i += 1;
109                }
110                continue;
111            }
112            // Block comment.
113            if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
114                i += 2;
115                while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
116                    i += 1;
117                }
118                i = i.saturating_add(2).min(bytes.len());
119                continue;
120            }
121            // Whitespace collapse.
122            if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
123                if !last_was_space {
124                    out.push(' ');
125                    last_was_space = true;
126                }
127                i += 1;
128                continue;
129            }
130            // String literal (single-quoted) with PG-style `''` escape.
131            if b == b'\'' {
132                i += 1;
133                while i < bytes.len() {
134                    if bytes[i] == b'\'' {
135                        if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
136                            i += 2; // escaped quote inside the literal
137                            continue;
138                        }
139                        i += 1;
140                        break;
141                    }
142                    i += 1;
143                }
144                param_counter += 1;
145                out.push('$');
146                let _ = core::fmt::Write::write_fmt(&mut out, format_args!("{param_counter}"));
147                last_was_space = false;
148                continue;
149            }
150            // Numeric literal. PG considers digits, an optional
151            // leading sign (already separated by the prev token),
152            // a decimal point, an optional exponent (`e±NN`). The
153            // safe boundary: previous emitted char is NOT an
154            // identifier char (`[A-Za-z0-9_]`). When the previous
155            // char IS an ident char, treat the digits as part of
156            // an identifier (e.g. `col42`).
157            if b.is_ascii_digit() {
158                let prev_is_ident = out
159                    .as_bytes()
160                    .last()
161                    .map(|c| c.is_ascii_alphanumeric() || *c == b'_')
162                    .unwrap_or(false);
163                if !prev_is_ident {
164                    while i < bytes.len()
165                        && (bytes[i].is_ascii_digit()
166                            || bytes[i] == b'.'
167                            || bytes[i] == b'e'
168                            || bytes[i] == b'E'
169                            || (bytes[i] == b'+' || bytes[i] == b'-')
170                                && i > 0
171                                && (bytes[i - 1] == b'e' || bytes[i - 1] == b'E'))
172                    {
173                        i += 1;
174                    }
175                    param_counter += 1;
176                    out.push('$');
177                    let _ = core::fmt::Write::write_fmt(&mut out, format_args!("{param_counter}"));
178                    last_was_space = false;
179                    continue;
180                }
181            }
182            // Default: copy through byte-for-byte. Identifier-like
183            // characters lower-case the alphabetic portion so
184            // `SELECT * FROM T` and `select * from t` collapse.
185            if b.is_ascii_uppercase() {
186                out.push(b.to_ascii_lowercase() as char);
187            } else {
188                out.push(b as char);
189            }
190            last_was_space = false;
191            i += 1;
192        }
193        // Trim trailing space.
194        if out.ends_with(' ') {
195            out.pop();
196        }
197        out
198    }
199
200    /// Record one execution. `elapsed_us` is the wall-clock micros
201    /// between start and end; `now_us` is the wall-clock micros at
202    /// completion (used for `last_seen_us`).
203    ///
204    /// v7.37.22 (22.6) — the sql key is normalised so distinct
205    /// literal-bearing instances collapse to a single template.
206    ///
207    /// Row-count is reported via [`Self::record_with_rows`]; this
208    /// shim defaults to 0 for callers that don't yet plumb the
209    /// affected / produced row count through.
210    pub fn record(&mut self, sql: &str, elapsed_us: u64, now_us: u64) {
211        self.record_with_rows(sql, elapsed_us, now_us, 0);
212    }
213
214    /// v7.37.22 (22.9) — full record path with row count tracking.
215    /// `rows` is the number of rows the executor produced
216    /// (SELECT result.len()) or affected (INSERT/UPDATE/DELETE
217    /// affected). Aggregates over the normalised template into
218    /// `total_rows` (cumulative) and `max_rows` (per-call peak).
219    pub fn record_with_rows(&mut self, sql: &str, elapsed_us: u64, now_us: u64, rows: u64) {
220        let sql = &Self::normalize_sql(sql);
221        let sql: &str = sql.as_str();
222        if let Some(stat) = self.entries.get_mut(sql) {
223            stat.exec_count = stat.exec_count.saturating_add(1);
224            stat.total_us = stat.total_us.saturating_add(elapsed_us);
225            stat.max_us = stat.max_us.max(elapsed_us);
226            stat.last_seen_us = now_us;
227            stat.total_rows = stat.total_rows.saturating_add(rows);
228            stat.max_rows = stat.max_rows.max(rows);
229            // Promote to MRU in lru queue.
230            if let Some(idx) = self.lru.iter().position(|k| k == sql) {
231                let key = self.lru.remove(idx).expect("idx from position");
232                self.lru.push_back(key);
233            }
234            return;
235        }
236        // New entry: enforce cap.
237        if self.entries.len() >= QUERY_STATS_MAX
238            && let Some(oldest) = self.lru.pop_front()
239        {
240            self.entries.remove(&oldest);
241        }
242        self.entries.insert(
243            String::from(sql),
244            QueryStat {
245                exec_count: 1,
246                total_us: elapsed_us,
247                max_us: elapsed_us,
248                last_seen_us: now_us,
249                total_rows: rows,
250                max_rows: rows,
251            },
252        );
253        self.lru.push_back(String::from(sql));
254    }
255
256    /// v6.5.6 — operator-controlled clear (e.g. for ops resets).
257    pub fn clear(&mut self) {
258        self.entries.clear();
259        self.lru.clear();
260    }
261
262    pub fn cap(&self) -> usize {
263        QUERY_STATS_MAX
264    }
265
266    /// Snapshot rows in LRU order (oldest → newest). Used by
267    /// `spg_stat_query` ORDER BY default.
268    pub fn snapshot(&self) -> Vec<(String, QueryStat)> {
269        self.lru
270            .iter()
271            .filter_map(|sql| self.entries.get(sql).map(|s| (sql.clone(), s.clone())))
272            .collect()
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use alloc::string::ToString;
280
281    #[test]
282    fn normalize_strips_numeric_literals() {
283        assert_eq!(
284            QueryStats::normalize_sql("SELECT * FROM t WHERE id = 1"),
285            "select * from t where id = $1"
286        );
287        assert_eq!(
288            QueryStats::normalize_sql("SELECT * FROM t WHERE id = 42 AND age > 30"),
289            "select * from t where id = $1 and age > $2"
290        );
291    }
292
293    #[test]
294    fn normalize_strips_string_literals() {
295        assert_eq!(
296            QueryStats::normalize_sql("SELECT * FROM t WHERE name = 'alice'"),
297            "select * from t where name = $1"
298        );
299        // Embedded escaped quote.
300        assert_eq!(QueryStats::normalize_sql("SELECT 'a''b'"), "select $1");
301    }
302
303    #[test]
304    fn normalize_keeps_identifiers_with_digit_suffix() {
305        assert_eq!(
306            QueryStats::normalize_sql("SELECT col42 FROM t"),
307            "select col42 from t"
308        );
309    }
310
311    #[test]
312    fn normalize_collapses_whitespace_and_strips_comments() {
313        assert_eq!(
314            QueryStats::normalize_sql(
315                "  SELECT *\n  -- pick everything\n  FROM /* yes */ t  WHERE id = 1"
316            ),
317            "select * from t where id = $1"
318        );
319    }
320
321    #[test]
322    fn record_with_rows_tracks_total_and_max() {
323        // v7.37.22 (22.9) — row count accumulates per template.
324        let mut qs = QueryStats::new();
325        qs.record_with_rows("SELECT * FROM t", 100, 1000, 5);
326        qs.record_with_rows("SELECT * FROM t", 200, 2000, 12);
327        qs.record_with_rows("SELECT * FROM t", 150, 3000, 3);
328        let s = qs.get("SELECT * FROM t").expect("present");
329        assert_eq!(s.exec_count, 3);
330        assert_eq!(s.total_rows, 5 + 12 + 3);
331        assert_eq!(s.max_rows, 12);
332    }
333
334    #[test]
335    fn record_zero_default_keeps_row_counters_at_zero() {
336        // The legacy `record(sql, elapsed, now)` shim should
337        // leave row counters at 0 — preserves backwards
338        // compatibility for callers that don't yet plumb rows.
339        let mut qs = QueryStats::new();
340        qs.record("SELECT 1", 100, 1000);
341        let s = qs.get("SELECT 1").expect("present");
342        assert_eq!(s.total_rows, 0);
343        assert_eq!(s.max_rows, 0);
344    }
345
346    #[test]
347    fn record_increments_counters() {
348        // v7.37.22 (22.6) — `SELECT 1` normalises to `select $1`.
349        // get() also normalises the lookup key. Two distinct
350        // calls collapse to one entry per normalised template.
351        let mut qs = QueryStats::new();
352        qs.record("SELECT 1", 100, 1000);
353        qs.record("SELECT 1", 200, 2000);
354        let s = qs
355            .entries
356            .get("select $1")
357            .expect("normalised template present");
358        assert_eq!(s.exec_count, 2);
359        assert_eq!(s.total_us, 300);
360        assert_eq!(s.max_us, 200);
361        assert_eq!(s.last_seen_us, 2000);
362    }
363
364    #[test]
365    fn distinct_sql_yields_separate_entries() {
366        // v7.37.22 (22.6) — only structurally different queries
367        // create separate entries. Different literals collapse.
368        let mut qs = QueryStats::new();
369        qs.record("SELECT a FROM t WHERE id = 1", 10, 100);
370        qs.record("SELECT a FROM t WHERE id = 2", 20, 200);
371        // Both collapse to `select a from t where id = $1`.
372        assert_eq!(qs.len(), 1);
373        qs.record("SELECT b FROM t WHERE id = 1", 30, 300);
374        // Now there's a structurally distinct template.
375        assert_eq!(qs.len(), 2);
376    }
377
378    #[test]
379    fn lru_evicts_oldest_at_cap() {
380        // v7.37.22 (22.6) — fill the cap with structurally
381        // distinct queries (different column names), then add
382        // one more and verify the oldest evicts.
383        let mut qs = QueryStats::new();
384        for i in 0..QUERY_STATS_MAX {
385            // Use distinct ident-shaped names so normalisation
386            // doesn't collapse them.
387            qs.record(&alloc::format!("SELECT c{i} FROM t"), 1, i as u64);
388        }
389        assert_eq!(qs.len(), QUERY_STATS_MAX);
390        qs.record("SELECT new_col FROM t", 1, QUERY_STATS_MAX as u64);
391        assert_eq!(qs.len(), QUERY_STATS_MAX);
392        assert!(
393            !qs.entries.contains_key("select c0 from t"),
394            "oldest evicted"
395        );
396        assert!(qs.entries.contains_key("select new_col from t"));
397    }
398
399    #[test]
400    fn re_recording_an_entry_promotes_lru() {
401        let mut qs = QueryStats::new();
402        qs.record("a", 1, 1);
403        qs.record("b", 1, 2);
404        qs.record("c", 1, 3);
405        // Touch "a" — should become MRU.
406        qs.record("a", 1, 4);
407        // Fill to cap so the next insert evicts the LRU front.
408        for i in 0..(QUERY_STATS_MAX - 3) {
409            qs.record(&alloc::format!("filler{i}"), 1, 100 + i as u64);
410        }
411        qs.record("trigger", 1, 9999);
412        assert!(qs.get("a").is_some(), "a was MRU; should survive");
413        assert!(qs.get("b").is_none(), "b should be evicted");
414    }
415
416    #[test]
417    fn clear_drops_everything() {
418        let mut qs = QueryStats::new();
419        qs.record("a", 1, 1);
420        qs.record("b", 1, 2);
421        qs.clear();
422        assert!(qs.is_empty());
423    }
424
425    #[test]
426    fn snapshot_returns_lru_order_oldest_first() {
427        let mut qs = QueryStats::new();
428        qs.record("a", 1, 100);
429        qs.record("b", 1, 200);
430        qs.record("c", 1, 300);
431        let snap = qs.snapshot();
432        let keys: Vec<String> = snap.iter().map(|(k, _)| k.clone()).collect();
433        assert_eq!(
434            keys,
435            alloc::vec!["a".to_string(), "b".to_string(), "c".to_string()]
436        );
437    }
438}