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}