Skip to main content

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>,
55}
56
57/// v7.29 - one batch-evaluated correlated subquery: the outer key
58/// column and the key -> value map.
59pub type GroupMap = (
60    spg_sql::ast::ColumnName,
61    alloc::collections::BTreeMap<String, Value>,
62);
63
64/// v7.29 (3c) - per-expression resolution plan: for the i-th scalar
65/// subquery node (pre-order) of a host expression, the shared batch
66/// map (None = unbatchable, resolve per row). Keyed by the HOST
67/// expression's address - callers guarantee the expression outlives
68/// the per-query memo (aggregate items / WHERE trees do). The stored
69/// subquery count guards against address reuse.
70/// (subquery count, per-subquery batch maps, hollow template). The
71/// template is the host expression with every scalar subquery BODY
72/// emptied - cloning it per row costs nodes, not whole subquery
73/// ASTs (the splice walk replaces the hollow nodes by pre-order).
74pub type ExprPlan = (
75    usize,
76    alloc::vec::Vec<Option<alloc::rc::Rc<GroupMap>>>,
77    spg_sql::ast::Expr,
78);
79
80/// v7.34 (mailrs conn-pool-exhaustion P0) - decorrelated `[NOT] EXISTS`
81/// semi/anti-join: the outer correlation columns (in key order) and the
82/// set of encoded inner key-tuples that have >=1 matching inner row,
83/// built in ONE scan. An outer row's EXISTS reduces to a membership
84/// test, turning O(outer x inner-exec) per-row work into O(scan + outer
85/// lookups) - PG's Hash Semi/Anti Join.
86pub type ExistsSet = (
87    alloc::vec::Vec<spg_sql::ast::ColumnName>,
88    alloc::collections::BTreeSet<String>,
89);
90
91/// v7.30.2 (mailrs round-25) - canonicalised membership set for a
92/// large all-literal `IN` list. Integer literals canonicalise to
93/// i64 (cross-width `Int = BigInt` stays correct); string literals
94/// stay verbatim. Mixed or exotic families are not eligible and
95/// keep the linear `apply_binary` scan.
96#[derive(Debug, Clone)]
97pub enum InListSet {
98    Int(alloc::collections::BTreeSet<i64>),
99    Text(alloc::collections::BTreeSet<String>),
100}
101
102#[derive(Debug, Clone)]
103pub struct InListSetEntry {
104    pub set: InListSet,
105    /// The list carried a NULL literal: a non-matching needle
106    /// yields NULL, not FALSE (SQL three-valued logic).
107    pub has_null: bool,
108}
109
110#[derive(Debug, Clone)]
111pub struct MemoizeCache {
112    /// LRU front = most recently used. Stored as a `VecDeque` so
113    /// re-promoting a hit is `O(n)` worst-case but `O(1)`
114    /// amortised for the common front-half-hit pattern of nested-
115    /// loop correlated subqueries.
116    entries: VecDeque<(CacheKey, Value)>,
117    /// v7.29 (round-22 phase 3) - batch-evaluated correlated scalar
118    /// subqueries: subquery repr -> Some((outer column, key -> value
119    /// map built in ONE pass)) or None when the shape can't batch
120    /// (so we don't re-analyse it per row). Turns 23.5k per-group
121    /// executions into one grouped scan + 23.5k lookups.
122    pub group_maps: alloc::collections::BTreeMap<String, Option<alloc::rc::Rc<GroupMap>>>,
123    /// v7.34 (mailrs conn-pool P0) - decorrelated `[NOT] EXISTS`: subquery
124    /// repr -> Some(semi/anti-join key-set) or None when the shape can't
125    /// decorrelate (don't re-analyse per row). Parallel to `group_maps`.
126    pub exists_sets: alloc::collections::BTreeMap<String, Option<alloc::rc::Rc<ExistsSet>>>,
127    /// v7.34.2 (EXISTS-FILTER baseline finding) — host-expression-ptr
128    /// indexed plan: walk the WHERE expr ONCE, collect every EXISTS
129    /// subquery in pre-order, build a decorrelated set for each, and
130    /// store them as a `Vec` indexed by pre-order position. Per-row
131    /// dispatch then walks the (cloned) expression in the same
132    /// pre-order, increments an ordinal cursor, and reads the matching
133    /// set out of this plan instead of re-running
134    /// `alloc::format!("{subquery}")` and a fresh BTreeMap probe per
135    /// row — the dominant cost of the 7.34.0 EXISTS-FILTER baseline.
136    /// `None` slot = couldn't decorrelate that particular EXISTS; the
137    /// dispatcher falls back to the legacy per-row resolver for it.
138    pub exists_plans: alloc::collections::BTreeMap<usize, Vec<Option<alloc::rc::Rc<ExistsSet>>>>,
139    /// v7.29 (3c) - host-expression ptr -> (subquery count, plan).
140    pub expr_plans: alloc::collections::BTreeMap<usize, ExprPlan>,
141    /// v7.30.2 (mailrs round-25) - InList node ptr -> membership set
142    /// for large all-literal `IN` lists, built once per row loop.
143    /// Turns the O(rows × list) membership scan into
144    /// O(rows × log list). `None` = analysed, not eligible.
145    pub in_sets: alloc::collections::BTreeMap<usize, Option<InListSetEntry>>,
146    /// v7.30.2 (mailrs round-25) - host-expression ptr -> "contains
147    /// a subquery node". The walk is O(tree) and a materialised IN
148    /// list makes the tree huge — caching it makes the per-row
149    /// dispatch O(log n) instead of O(24k list elements).
150    pub has_subquery: alloc::collections::BTreeMap<usize, bool>,
151    max_entries: usize,
152    max_bytes: usize,
153    current_bytes: usize,
154    pub hit_count: u64,
155    pub miss_count: u64,
156}
157
158impl Default for MemoizeCache {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164impl MemoizeCache {
165    pub fn new() -> Self {
166        Self {
167            entries: VecDeque::with_capacity(DEFAULT_MAX_ENTRIES),
168            max_entries: DEFAULT_MAX_ENTRIES,
169            max_bytes: DEFAULT_MAX_BYTES,
170            current_bytes: 0,
171            hit_count: 0,
172            miss_count: 0,
173            group_maps: alloc::collections::BTreeMap::new(),
174            exists_sets: alloc::collections::BTreeMap::new(),
175            exists_plans: alloc::collections::BTreeMap::new(),
176            expr_plans: alloc::collections::BTreeMap::new(),
177            in_sets: alloc::collections::BTreeMap::new(),
178            has_subquery: alloc::collections::BTreeMap::new(),
179        }
180    }
181
182    pub const fn with_max_entries(mut self, n: usize) -> Self {
183        self.max_entries = n;
184        self
185    }
186
187    pub const fn with_max_bytes(mut self, b: usize) -> Self {
188        self.max_bytes = b;
189        self
190    }
191
192    pub fn len(&self) -> usize {
193        self.entries.len()
194    }
195
196    pub fn is_empty(&self) -> bool {
197        self.entries.is_empty()
198    }
199
200    /// Look up a cached scalar value. On hit, re-promotes the
201    /// entry to the LRU front and bumps `hit_count`. On miss,
202    /// returns `None` (caller runs the subquery + `insert`s).
203    pub fn get(&mut self, key: &CacheKey) -> Option<Value> {
204        let pos = self.entries.iter().position(|(k, _)| k == key);
205        if let Some(p) = pos {
206            let (k, v) = self.entries.remove(p)?;
207            self.entries.push_front((k, v.clone()));
208            self.hit_count += 1;
209            Some(v)
210        } else {
211            self.miss_count += 1;
212            None
213        }
214    }
215
216    /// Insert a freshly-computed scalar value. Caller must have
217    /// `get`-missed first (the cache doesn't dedupe inserts).
218    /// Evicts LRU entries until both caps are satisfied.
219    pub fn insert(&mut self, key: CacheKey, value: Value) {
220        let entry_bytes = approx_bytes(&key) + approx_value_bytes(&value);
221        while !self.entries.is_empty()
222            && (self.entries.len() >= self.max_entries
223                || self.current_bytes + entry_bytes > self.max_bytes)
224        {
225            let Some((k, v)) = self.entries.pop_back() else {
226                break;
227            };
228            self.current_bytes = self
229                .current_bytes
230                .saturating_sub(approx_bytes(&k) + approx_value_bytes(&v));
231        }
232        self.current_bytes = self.current_bytes.saturating_add(entry_bytes);
233        self.entries.push_front((key, value));
234    }
235}
236
237fn approx_bytes(key: &CacheKey) -> usize {
238    key.subquery_repr.len()
239        + key
240            .outer_values
241            .iter()
242            .map(approx_value_bytes)
243            .sum::<usize>()
244        + 16
245}
246
247fn approx_value_bytes(v: &Value) -> usize {
248    match v {
249        Value::Null | Value::Bool(_) | Value::SmallInt(_) => 1,
250        Value::Int(_) => 4,
251        Value::BigInt(_) | Value::Float(_) => 8,
252        Value::Date(_) | Value::Timestamp(_) => 8,
253        Value::Interval { .. } => 16,
254        Value::Numeric { .. } => 16,
255        Value::Text(s) | Value::Json(s) => s.len(),
256        Value::Vector(v) => v.len() * 4,
257        Value::Sq8Vector(q) => q.bytes.len() + 8,
258        Value::HalfVector(h) => h.dim() * 2,
259        // v7.5.0 — Value is #[non_exhaustive]; conservative estimate.
260        _ => 16,
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn key(repr: &str, outer: &[Value]) -> CacheKey {
269        CacheKey {
270            subquery_repr: repr.into(),
271            outer_values: outer.to_vec(),
272        }
273    }
274
275    #[test]
276    fn empty_cache_misses_everything() {
277        let mut c = MemoizeCache::new();
278        let k = key("SELECT 1", &[Value::Int(1)]);
279        assert!(c.get(&k).is_none());
280        assert_eq!(c.miss_count, 1);
281        assert_eq!(c.hit_count, 0);
282    }
283
284    #[test]
285    fn insert_then_get_hits() {
286        let mut c = MemoizeCache::new();
287        let k = key("SELECT 1", &[Value::Int(1)]);
288        c.insert(k.clone(), Value::BigInt(42));
289        let v = c.get(&k);
290        assert_eq!(v, Some(Value::BigInt(42)));
291        assert_eq!(c.hit_count, 1);
292    }
293
294    #[test]
295    fn repeated_outer_key_hits_after_first_insert() {
296        let mut c = MemoizeCache::new();
297        let repr = "SELECT MAX(x) FROM y WHERE y.k = outer.k";
298        for i in 0..100 {
299            let k = key(repr, &[Value::Int(i % 5)]);
300            if c.get(&k).is_none() {
301                c.insert(k, Value::BigInt(i64::from(i)));
302            }
303        }
304        // 5 unique keys → 5 misses, 95 hits.
305        assert_eq!(c.miss_count, 5);
306        assert_eq!(c.hit_count, 95);
307    }
308
309    #[test]
310    fn lru_eviction_at_max_entries() {
311        let mut c = MemoizeCache::new().with_max_entries(3);
312        for i in 0..5 {
313            let k = key("q", &[Value::Int(i)]);
314            c.insert(k, Value::BigInt(i64::from(i)));
315        }
316        assert!(c.len() <= 3, "len={}", c.len());
317        // Last 3 inserted (i=2, 3, 4) should be the survivors.
318        assert!(c.get(&key("q", &[Value::Int(4)])).is_some());
319        assert!(c.get(&key("q", &[Value::Int(3)])).is_some());
320        assert!(c.get(&key("q", &[Value::Int(2)])).is_some());
321        // Older entries evicted.
322        assert!(c.get(&key("q", &[Value::Int(0)])).is_none());
323    }
324
325    #[test]
326    fn lru_eviction_at_max_bytes() {
327        let mut c = MemoizeCache::new().with_max_bytes(128);
328        // Big strings exceed 128 bytes fast.
329        for i in 0..10 {
330            let big_str = alloc::string::String::from_iter(core::iter::repeat_n('x', 64));
331            c.insert(key("q", &[Value::Int(i)]), Value::Text(big_str));
332        }
333        assert!(c.len() < 10, "len={}", c.len());
334    }
335
336    #[test]
337    fn distinct_subquery_reprs_dont_collide() {
338        let mut c = MemoizeCache::new();
339        let k1 = key("SELECT 1", &[Value::Int(1)]);
340        let k2 = key("SELECT 2", &[Value::Int(1)]);
341        c.insert(k1.clone(), Value::BigInt(10));
342        c.insert(k2.clone(), Value::BigInt(20));
343        assert_eq!(c.get(&k1), Some(Value::BigInt(10)));
344        assert_eq!(c.get(&k2), Some(Value::BigInt(20)));
345    }
346
347    #[test]
348    fn miss_then_hit_bumps_promotes_to_lru_front() {
349        let mut c = MemoizeCache::new().with_max_entries(3);
350        c.insert(key("q", &[Value::Int(0)]), Value::BigInt(0));
351        c.insert(key("q", &[Value::Int(1)]), Value::BigInt(1));
352        c.insert(key("q", &[Value::Int(2)]), Value::BigInt(2));
353        // Touch 0 — promote to front.
354        let _ = c.get(&key("q", &[Value::Int(0)]));
355        // Insert a new entry — evicts the LRU (which is now 1, not 0).
356        c.insert(key("q", &[Value::Int(3)]), Value::BigInt(3));
357        assert!(c.get(&key("q", &[Value::Int(0)])).is_some());
358        assert!(c.get(&key("q", &[Value::Int(1)])).is_none());
359    }
360}