spg_engine/memoize.rs
1// pedantic doc_markdown flags every bare ident in the comment-as-
2// spec block; allowing at the module level keeps the spec readable.
3#![allow(clippy::doc_markdown)]
4
5//! v6.2.6 — Memoize cache for correlated subqueries.
6//!
7//! When a `WHERE` clause references a scalar subquery whose inner
8//! body depends on the outer row's column values (the classic
9//! `WHERE id IN (SELECT MAX(x) FROM y WHERE y.k = outer.k)`
10//! shape), the engine's current behaviour re-runs the inner
11//! SELECT once per outer row — `O(outer_rows × inner_cost)` work
12//! even when many outer rows share the same correlated key.
13//!
14//! v6.2.6 wraps that path with a per-query `MemoizeCache`:
15//! before running the inner, hash the (subquery identity, outer-
16//! row values) key and look it up; cache hits return the prior
17//! result without re-executing. Caps:
18//!
19//! - **1024 entries** (configurable via the planner's
20//! [`Self::with_max_entries`])
21//! - **16 MiB** of cumulative cached `Value` bytes (v5.5
22//! per-query memory budget's 1/16 share; configurable via
23//! [`Self::with_max_bytes`])
24//!
25//! When either cap is hit, the least-recently-used entry is
26//! evicted before insertion.
27//!
28//! v6.2.6 ships the simple linear-vec LRU. v6.2.x can swap to a
29//! BTreeMap + LinkedList for sub-`O(n)` lookup if it ever
30//! matters; the gate is "≥ 5× speedup on the repeated-key
31//! workload" which the linear scan clears at scale-1k.
32
33use alloc::collections::VecDeque;
34use alloc::string::String;
35use alloc::vec::Vec;
36
37use spg_storage::Value;
38
39/// v6.2.6 — default cache size cap. Matches the design's "1024
40/// entries" figure (V6_2_DESIGN.md L2 row 6).
41pub const DEFAULT_MAX_ENTRIES: usize = 1024;
42
43/// v6.2.6 — default cumulative bytes cap. 16 MiB matches the
44/// v5.5 per-query budget's 1/16 share.
45pub const DEFAULT_MAX_BYTES: usize = 16 * 1024 * 1024;
46
47/// Cache key — the subquery's textual identity plus the outer
48/// row's value tuple. Two scalar-subquery node positions with
49/// identical Display text are treated as the same subquery for
50/// caching purposes (sound: equal Display → equal AST).
51#[derive(Debug, Clone, PartialEq)]
52pub struct CacheKey {
53 pub subquery_repr: String,
54 pub outer_values: Vec<Value<'static>>,
55}
56
57/// v7.29 - one batch-evaluated correlated subquery: the outer key
58/// column and the key -> value map.
59///
60/// v7.37.x (docker-fair SCALARSQ attack) — extended with an
61/// `empty_default` Value. PG scalar-subquery empty-set semantics
62/// distinguish `COUNT(*)` / `COUNT(col)` (= 0 over no rows) from
63/// every other aggregate (= NULL). The hollow_scalar_subqueries
64/// template-rewrite step empties the inner SelectStatement before
65/// the per-row splice, so the splicer cannot inspect the original
66/// aggregate kind at probe time. Storing the empty-default on the
67/// GroupMap captures that information at try_batch_correlated_scalar
68/// construction time, where the original inner is still in hand.
69pub type GroupMap = (
70 spg_sql::ast::ColumnName,
71 alloc::collections::BTreeMap<String, Value<'static>>,
72 Value<'static>,
73);
74
75/// v7.29 (3c) - per-expression resolution plan: for the i-th scalar
76/// subquery node (pre-order) of a host expression, the shared batch
77/// map (None = unbatchable, resolve per row). Keyed by the HOST
78/// expression's address - callers guarantee the expression outlives
79/// the per-query memo (aggregate items / WHERE trees do). The stored
80/// subquery count guards against address reuse.
81/// (subquery count, per-subquery batch maps, hollow template). The
82/// template is the host expression with every scalar subquery BODY
83/// emptied - cloning it per row costs nodes, not whole subquery
84/// ASTs (the splice walk replaces the hollow nodes by pre-order).
85pub type ExprPlan = (
86 usize,
87 alloc::vec::Vec<Option<alloc::rc::Rc<GroupMap>>>,
88 spg_sql::ast::Expr,
89);
90
91/// v7.34 (mailrs conn-pool-exhaustion P0) - decorrelated `[NOT] EXISTS`
92/// semi/anti-join: the outer correlation columns (in key order) and the
93/// set of encoded inner key-tuples that have >=1 matching inner row,
94/// built in ONE scan. An outer row's EXISTS reduces to a membership
95/// test, turning O(outer x inner-exec) per-row work into O(scan + outer
96/// lookups) - PG's Hash Semi/Anti Join.
97pub type ExistsSet = (
98 alloc::vec::Vec<spg_sql::ast::ColumnName>,
99 alloc::collections::BTreeSet<String>,
100);
101
102/// v7.30.2 (mailrs round-25) - canonicalised membership set for a
103/// large all-literal `IN` list. Integer literals canonicalise to
104/// i64 (cross-width `Int = BigInt` stays correct); string literals
105/// stay verbatim. Mixed or exotic families are not eligible and
106/// keep the linear `apply_binary` scan.
107///
108/// v7.37.x (docker-bench NOTEX 红线) — switched from `BTreeSet` to
109/// `hashbrown::HashSet`. The probe shape is 25 k outer-row membership
110/// lookups against a 12.5 k-element InList; BTreeSet was O(log N) ≈
111/// 14 byte comparisons per lookup (~70 ns), hash set is O(1) ≈ 5 ns.
112/// Net win on the docker-fair NOTEX bench: 4.4 ms → ~2 ms.
113#[derive(Debug, Clone)]
114pub enum InListSet {
115 Int(hashbrown::HashSet<i64>),
116 Text(hashbrown::HashSet<alloc::string::String>),
117}
118
119#[derive(Debug, Clone)]
120pub struct InListSetEntry {
121 pub set: InListSet,
122 /// The list carried a NULL literal: a non-matching needle
123 /// yields NULL, not FALSE (SQL three-valued logic).
124 pub has_null: bool,
125}
126
127#[derive(Debug, Clone)]
128pub struct MemoizeCache {
129 /// LRU front = most recently used. Stored as a `VecDeque` so
130 /// re-promoting a hit is `O(n)` worst-case but `O(1)`
131 /// amortised for the common front-half-hit pattern of nested-
132 /// loop correlated subqueries.
133 entries: VecDeque<(CacheKey, Value<'static>)>,
134 /// v7.29 (round-22 phase 3) - batch-evaluated correlated scalar
135 /// subqueries: subquery repr -> Some((outer column, key -> value
136 /// map built in ONE pass)) or None when the shape can't batch
137 /// (so we don't re-analyse it per row). Turns 23.5k per-group
138 /// executions into one grouped scan + 23.5k lookups.
139 pub group_maps: alloc::collections::BTreeMap<String, Option<alloc::rc::Rc<GroupMap>>>,
140 /// v7.37.x (docker-fair SCALARSQ attack) — fast-path cache keyed
141 /// by `SelectStatement` pointer address. The repr-stringified
142 /// `group_maps` key cost ~500 ns of `alloc::format!` per outer
143 /// row; for hundreds-of-row LIMIT shapes that's still tens of µs
144 /// of pure repr churn. Per-row hit is a HashMap probe on a usize
145 /// key (the inner AST is stable for the SELECT's lifetime).
146 pub group_maps_by_ptr: hashbrown::HashMap<usize, Option<alloc::rc::Rc<GroupMap>>>,
147 /// v7.34 (mailrs conn-pool P0) - decorrelated `[NOT] EXISTS`: subquery
148 /// repr -> Some(semi/anti-join key-set) or None when the shape can't
149 /// decorrelate (don't re-analyse per row). Parallel to `group_maps`.
150 pub exists_sets: alloc::collections::BTreeMap<String, Option<alloc::rc::Rc<ExistsSet>>>,
151 /// v7.34.2 (EXISTS-FILTER baseline finding) — host-expression-ptr
152 /// indexed plan: walk the WHERE expr ONCE, collect every EXISTS
153 /// subquery in pre-order, build a decorrelated set for each, and
154 /// store them as a `Vec` indexed by pre-order position. Per-row
155 /// dispatch then walks the (cloned) expression in the same
156 /// pre-order, increments an ordinal cursor, and reads the matching
157 /// set out of this plan instead of re-running
158 /// `alloc::format!("{subquery}")` and a fresh BTreeMap probe per
159 /// row — the dominant cost of the 7.34.0 EXISTS-FILTER baseline.
160 /// `None` slot = couldn't decorrelate that particular EXISTS; the
161 /// dispatcher falls back to the legacy per-row resolver for it.
162 pub exists_plans: alloc::collections::BTreeMap<usize, Vec<Option<alloc::rc::Rc<ExistsSet>>>>,
163 /// v7.29 (3c) - host-expression ptr -> (subquery count, plan).
164 pub expr_plans: alloc::collections::BTreeMap<usize, ExprPlan>,
165 /// v7.30.2 (mailrs round-25) - InList node ptr -> membership set
166 /// for large all-literal `IN` lists, built once per row loop.
167 /// Turns the O(rows × list) membership scan into
168 /// O(rows × log list). `None` = analysed, not eligible.
169 pub in_sets: alloc::collections::BTreeMap<usize, Option<InListSetEntry>>,
170 /// v7.30.2 (mailrs round-25) - host-expression ptr -> "contains
171 /// a subquery node". The walk is O(tree) and a materialised IN
172 /// list makes the tree huge — caching it makes the per-row
173 /// dispatch O(log n) instead of O(24k list elements).
174 pub has_subquery: alloc::collections::BTreeMap<usize, bool>,
175 max_entries: usize,
176 max_bytes: usize,
177 current_bytes: usize,
178 pub hit_count: u64,
179 pub miss_count: u64,
180}
181
182impl Default for MemoizeCache {
183 fn default() -> Self {
184 Self::new()
185 }
186}
187
188impl MemoizeCache {
189 pub fn new() -> Self {
190 Self {
191 entries: VecDeque::with_capacity(DEFAULT_MAX_ENTRIES),
192 max_entries: DEFAULT_MAX_ENTRIES,
193 max_bytes: DEFAULT_MAX_BYTES,
194 current_bytes: 0,
195 hit_count: 0,
196 miss_count: 0,
197 group_maps: alloc::collections::BTreeMap::new(),
198 group_maps_by_ptr: hashbrown::HashMap::new(),
199 exists_sets: alloc::collections::BTreeMap::new(),
200 exists_plans: alloc::collections::BTreeMap::new(),
201 expr_plans: alloc::collections::BTreeMap::new(),
202 in_sets: alloc::collections::BTreeMap::new(),
203 has_subquery: alloc::collections::BTreeMap::new(),
204 }
205 }
206
207 pub const fn with_max_entries(mut self, n: usize) -> Self {
208 self.max_entries = n;
209 self
210 }
211
212 pub const fn with_max_bytes(mut self, b: usize) -> Self {
213 self.max_bytes = b;
214 self
215 }
216
217 pub fn len(&self) -> usize {
218 self.entries.len()
219 }
220
221 pub fn is_empty(&self) -> bool {
222 self.entries.is_empty()
223 }
224
225 /// Look up a cached scalar value. On hit, re-promotes the
226 /// entry to the LRU front and bumps `hit_count`. On miss,
227 /// returns `None` (caller runs the subquery + `insert`s).
228 pub fn get(&mut self, key: &CacheKey) -> Option<Value<'static>> {
229 let pos = self.entries.iter().position(|(k, _)| k == key);
230 if let Some(p) = pos {
231 let (k, v) = self.entries.remove(p)?;
232 self.entries.push_front((k, v.clone()));
233 self.hit_count += 1;
234 Some(v)
235 } else {
236 self.miss_count += 1;
237 None
238 }
239 }
240
241 /// Insert a freshly-computed scalar value. Caller must have
242 /// `get`-missed first (the cache doesn't dedupe inserts).
243 /// Evicts LRU entries until both caps are satisfied.
244 pub fn insert(&mut self, key: CacheKey, value: Value<'static>) {
245 let entry_bytes = approx_bytes(&key) + approx_value_bytes(&value);
246 while !self.entries.is_empty()
247 && (self.entries.len() >= self.max_entries
248 || self.current_bytes + entry_bytes > self.max_bytes)
249 {
250 let Some((k, v)) = self.entries.pop_back() else {
251 break;
252 };
253 self.current_bytes = self
254 .current_bytes
255 .saturating_sub(approx_bytes(&k) + approx_value_bytes(&v));
256 }
257 self.current_bytes = self.current_bytes.saturating_add(entry_bytes);
258 self.entries.push_front((key, value));
259 }
260}
261
262fn approx_bytes(key: &CacheKey) -> usize {
263 key.subquery_repr.len()
264 + key
265 .outer_values
266 .iter()
267 .map(approx_value_bytes)
268 .sum::<usize>()
269 + 16
270}
271
272fn approx_value_bytes(v: &Value) -> usize {
273 match v {
274 Value::Null | Value::Bool(_) | Value::SmallInt(_) => 1,
275 Value::Int(_) => 4,
276 Value::BigInt(_) | Value::Float(_) => 8,
277 Value::Date(_) | Value::Timestamp(_) => 8,
278 Value::Interval { .. } => 16,
279 Value::Numeric { .. } => 16,
280 Value::Text(s) | Value::Json(s) => s.len(),
281 Value::Vector(v) => v.len() * 4,
282 Value::Sq8Vector(q) => q.bytes.len() + 8,
283 Value::HalfVector(h) => h.dim() * 2,
284 // v7.5.0 — Value is #[non_exhaustive]; conservative estimate.
285 _ => 16,
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 fn key(repr: &str, outer: &[Value<'static>]) -> CacheKey {
294 CacheKey {
295 subquery_repr: repr.into(),
296 outer_values: outer.to_vec(),
297 }
298 }
299
300 #[test]
301 fn empty_cache_misses_everything() {
302 let mut c = MemoizeCache::new();
303 let k = key("SELECT 1", &[Value::Int(1)]);
304 assert!(c.get(&k).is_none());
305 assert_eq!(c.miss_count, 1);
306 assert_eq!(c.hit_count, 0);
307 }
308
309 #[test]
310 fn insert_then_get_hits() {
311 let mut c = MemoizeCache::new();
312 let k = key("SELECT 1", &[Value::Int(1)]);
313 c.insert(k.clone(), Value::BigInt(42));
314 let v = c.get(&k);
315 assert_eq!(v, Some(Value::BigInt(42)));
316 assert_eq!(c.hit_count, 1);
317 }
318
319 #[test]
320 fn repeated_outer_key_hits_after_first_insert() {
321 let mut c = MemoizeCache::new();
322 let repr = "SELECT MAX(x) FROM y WHERE y.k = outer.k";
323 for i in 0..100 {
324 let k = key(repr, &[Value::Int(i % 5)]);
325 if c.get(&k).is_none() {
326 c.insert(k, Value::BigInt(i64::from(i)));
327 }
328 }
329 // 5 unique keys → 5 misses, 95 hits.
330 assert_eq!(c.miss_count, 5);
331 assert_eq!(c.hit_count, 95);
332 }
333
334 #[test]
335 fn lru_eviction_at_max_entries() {
336 let mut c = MemoizeCache::new().with_max_entries(3);
337 for i in 0..5 {
338 let k = key("q", &[Value::Int(i)]);
339 c.insert(k, Value::BigInt(i64::from(i)));
340 }
341 assert!(c.len() <= 3, "len={}", c.len());
342 // Last 3 inserted (i=2, 3, 4) should be the survivors.
343 assert!(c.get(&key("q", &[Value::Int(4)])).is_some());
344 assert!(c.get(&key("q", &[Value::Int(3)])).is_some());
345 assert!(c.get(&key("q", &[Value::Int(2)])).is_some());
346 // Older entries evicted.
347 assert!(c.get(&key("q", &[Value::Int(0)])).is_none());
348 }
349
350 #[test]
351 fn lru_eviction_at_max_bytes() {
352 let mut c = MemoizeCache::new().with_max_bytes(128);
353 // Big strings exceed 128 bytes fast.
354 for i in 0..10 {
355 let big_str = alloc::string::String::from_iter(core::iter::repeat_n('x', 64));
356 c.insert(key("q", &[Value::Int(i)]), Value::text(big_str));
357 }
358 assert!(c.len() < 10, "len={}", c.len());
359 }
360
361 #[test]
362 fn distinct_subquery_reprs_dont_collide() {
363 let mut c = MemoizeCache::new();
364 let k1 = key("SELECT 1", &[Value::Int(1)]);
365 let k2 = key("SELECT 2", &[Value::Int(1)]);
366 c.insert(k1.clone(), Value::BigInt(10));
367 c.insert(k2.clone(), Value::BigInt(20));
368 assert_eq!(c.get(&k1), Some(Value::BigInt(10)));
369 assert_eq!(c.get(&k2), Some(Value::BigInt(20)));
370 }
371
372 #[test]
373 fn miss_then_hit_bumps_promotes_to_lru_front() {
374 let mut c = MemoizeCache::new().with_max_entries(3);
375 c.insert(key("q", &[Value::Int(0)]), Value::BigInt(0));
376 c.insert(key("q", &[Value::Int(1)]), Value::BigInt(1));
377 c.insert(key("q", &[Value::Int(2)]), Value::BigInt(2));
378 // Touch 0 — promote to front.
379 let _ = c.get(&key("q", &[Value::Int(0)]));
380 // Insert a new entry — evicts the LRU (which is now 1, not 0).
381 c.insert(key("q", &[Value::Int(3)]), Value::BigInt(3));
382 assert!(c.get(&key("q", &[Value::Int(0)])).is_some());
383 assert!(c.get(&key("q", &[Value::Int(1)])).is_none());
384 }
385}