spg-engine 7.37.16

Execution engine for SPG: glues spg-sql parsing to spg-storage. Foreign keys, joins, vectors, cold tier.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! Read-only / snapshot execution, split out of `lib.rs` (lib.rs split
//! 18). Two entry families share one module: the live read path
//! (`execute_readonly` / `_with_cancel`, taken by the server under an
//! `RwLock::read()` so SELECTs run in parallel) and the snapshot path
//! (`execute_readonly_on_snapshot` / the prepared + describe variants /
//! `is_readonly_sql` / `prepare_on_snapshot`), which run against a
//! `CatalogSnapshot` without borrowing the engine. Both reject DDL/DML
//! with `WriteRequired` and route SELECT / SHOW / EXPLAIN to the same
//! domain handlers as the write path. Whole `impl Engine` methods; the
//! public surface is unchanged, and `enforce_row_limit` stays in the
//! crate root (shared with `execute.rs`, reached via self).

use alloc::vec::Vec;

use spg_sql::ast::Statement;
use spg_sql::parser::{self, ParseError};
use spg_storage::{ColumnSchema, Value};

use crate::describe;
use crate::{
    CancelToken, CatalogSnapshot, Engine, EngineError, QueryResult, expand_group_by_all, reorder,
    resolve_order_by_position, rewrite_clock_calls, substitute_placeholders,
};

impl Engine {
    /// v7.11.1 — execute a read-only SQL statement against a
    /// `CatalogSnapshot` without touching this engine. Same
    /// semantics as `execute_readonly` but parameterised on the
    /// snapshot's catalog. Reject DDL/DML the same way
    /// `execute_readonly` does. Static-on-Self so the caller can
    /// dispatch without holding an `Engine` borrow alongside the
    /// snapshot.
    pub fn execute_readonly_on_snapshot(
        snapshot: &CatalogSnapshot,
        sql: &str,
    ) -> Result<QueryResult, EngineError> {
        Self::execute_readonly_on_snapshot_with_cancel(snapshot, sql, CancelToken::none())
    }

    /// v7.11.1 — `execute_readonly_on_snapshot` with cooperative
    /// cancellation. Builds a transient `Engine` over the snapshot
    /// state, runs `execute_readonly_with_cancel`, drops. The
    /// transient engine is cheap to construct (no I/O; everything
    /// is just struct moves) and lets the existing read path stay
    /// untouched.
    pub fn execute_readonly_on_snapshot_with_cancel(
        snapshot: &CatalogSnapshot,
        sql: &str,
        cancel: CancelToken<'_>,
    ) -> Result<QueryResult, EngineError> {
        let transient = Engine {
            catalog: snapshot.catalog.clone(),
            statistics: snapshot.statistics.clone(),
            clock: snapshot.clock,
            max_query_rows: snapshot.max_query_rows,
            ..Engine::default()
        };
        transient.execute_readonly_with_cancel(sql, cancel)
    }

    /// v7.18 — execute a previously-prepared `Statement` against a
    /// `CatalogSnapshot` in read-only mode. Mirror of
    /// [`Engine::execute_prepared`] for the fan-out read path:
    /// substitutes `Expr::Placeholder(n)` nodes from `params`, then
    /// dispatches through [`Engine::execute_readonly_stmt_with_cancel`]
    /// (writes / DDL hit `EngineError::WriteRequired`). Static-on-Self
    /// so multiple readonly threads can dispatch against the same
    /// snapshot concurrently without an `Engine` borrow.
    ///
    /// **Schema drift contract**. The `Statement` was prepared against
    /// some prior catalog. If the snapshot's catalog has since
    /// diverged (DDL renamed / dropped a referenced column / table),
    /// execution surfaces the normal `EngineError` — same shape as
    /// PG's "cached plan must not change result type". Caller decides
    /// whether to re-prepare; engine does NOT auto-retry.
    pub fn execute_readonly_prepared_on_snapshot(
        snapshot: &CatalogSnapshot,
        stmt: Statement,
        params: &[Value<'static>],
    ) -> Result<QueryResult, EngineError> {
        Self::execute_readonly_prepared_on_snapshot_with_cancel(
            snapshot,
            stmt,
            params,
            CancelToken::none(),
        )
    }

    /// v7.18 — cancellable variant of
    /// [`Engine::execute_readonly_prepared_on_snapshot`].
    pub fn execute_readonly_prepared_on_snapshot_with_cancel(
        snapshot: &CatalogSnapshot,
        mut stmt: Statement,
        params: &[Value<'static>],
        cancel: CancelToken<'_>,
    ) -> Result<QueryResult, EngineError> {
        cancel.check()?;
        substitute_placeholders(&mut stmt, params)?;
        let transient = Engine {
            catalog: snapshot.catalog.clone(),
            statistics: snapshot.statistics.clone(),
            clock: snapshot.clock,
            max_query_rows: snapshot.max_query_rows,
            ..Engine::default()
        };
        transient.execute_readonly_stmt_with_cancel(stmt, cancel)
    }

    /// v7.18 — describe a prepared `Statement` against a
    /// `CatalogSnapshot`. Same `(parameter_oids, output_columns)`
    /// shape as [`Engine::describe_prepared`]; resolves names
    /// against the snapshot's catalog instead of `self`. Pure
    /// function — no engine state read.
    pub fn describe_prepared_on_snapshot(
        snapshot: &CatalogSnapshot,
        stmt: &Statement,
    ) -> (Vec<u32>, Vec<ColumnSchema>) {
        describe::describe_prepared(stmt, &snapshot.catalog)
    }

    /// v7.18 — does this SQL string classify as read-only? Parses
    /// `sql` with the engine parser and consults
    /// `Statement::is_readonly()`. A parse error returns `false`
    /// (route to the writer path so the user sees the canonical
    /// parse error from the writer's simple-query dispatch).
    /// Static-on-Self so the spg-sqlx connection layer can ask
    /// without an `Engine` borrow.
    #[must_use]
    pub fn is_readonly_sql(sql: &str) -> bool {
        parser::parse_statement(sql)
            .as_ref()
            .map(spg_sql::ast::Statement::is_readonly)
            .unwrap_or(false)
    }

    /// v7.18 — parse + plan a SQL string against a
    /// `CatalogSnapshot`. Mirror of [`Engine::prepare`] for the
    /// readonly fan-out path: applies the same prepare-time
    /// transforms (clock rewrite, `GROUP BY ALL` expansion, ORDER
    /// BY position resolve, cost-based JOIN reorder) but resolves
    /// catalog + statistics against the snapshot, not a live
    /// engine. Static-on-Self — `AsyncReadHandle::prepare` calls
    /// this without taking the writer lock so multiple read
    /// handles can prepare concurrently against frozen views.
    ///
    /// # Errors
    /// Propagates [`ParseError`] from the parser. Schema
    /// validation deferred to execute time, same as
    /// [`Engine::prepare`].
    pub fn prepare_on_snapshot(
        snapshot: &CatalogSnapshot,
        sql: &str,
    ) -> Result<Statement, ParseError> {
        let mut stmt = parser::parse_statement(sql)?;
        let now_micros = snapshot.clock.map(|f| f());
        // A snapshot carries no session, so PG's reading — the stricter
        // one — is the honest default here.
        // A snapshot carries no session, so there is no zone to read the
        // local-clock family in — UTC, as before.
        rewrite_clock_calls(&mut stmt, now_micros, false, 0);
        if let Statement::Select(s) = &mut stmt {
            expand_group_by_all(s);
            resolve_order_by_position(s);
            reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
        }
        Ok(stmt)
    }

    /// **v4.0 concurrency**: this is the entry point the server takes
    /// under an `RwLock::read()` so multiple `SELECT` clients run in
    /// parallel without serialising on a single mutex.
    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
        self.execute_readonly_with_cancel(sql, CancelToken::none())
    }

    /// v7.37.x (SPGS PROJ wire encode tax) — read-path streaming
    /// SELECT. Parses the SQL, applies the same statement-level
    /// rewrites the read path does (`rewrite_clock_calls`,
    /// `resolve_order_by_position`, `reorder::reorder_joins`), then
    /// drives the streaming SELECT executor with the caller's emit
    /// callback. For PROJ-shape SQLs (joined non-aggregate projection
    /// of bound columns over thousands of rows) the engine produces
    /// each row to the emit fn WITHOUT materialising the result into
    /// `Vec<Row<'static>>` — the per-cell `.cloned()` and per-row
    /// `Row::new(values)` disappear. On the 25 k-row PROJ shape
    /// that's about 4 ms saved (one less full result allocation pass
    /// at the engine output boundary).
    ///
    /// Returns the surviving row count emitted (post-WHERE,
    /// post-LIMIT) for the `CommandComplete` tag. Non-SELECT
    /// statements surface as `Unsupported` so the caller can fall
    /// back to the materialising read path.
    /// v7.37.x (docker-fair SCALARSQ wire-overhead attack) — prepared-
    /// SelectStatement variant. Caller has already run
    /// `parser::parse_statement_with` + `rewrite_clock_calls` +
    /// `resolve_order_by_position` + `reorder::reorder_joins` (the
    /// per-connection parse cache in spg-server's pgwire layer caches
    /// the post-prepare AST and re-applies `rewrite_clock_calls` per
    /// invocation since the clock value embedded in the AST drifts).
    /// Otherwise identical to the SQL-string entry point.
    pub fn prepare_select_streaming(
        &self,
        sql: &str,
    ) -> Result<spg_sql::ast::SelectStatement, EngineError> {
        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
        let now_micros = self.clock.map(|f| f());
        rewrite_clock_calls(
            &mut stmt,
            now_micros,
            self.backslash_escapes,
            now_micros.map_or(0, |n| self.session_tz_offset_at(n)),
        );
        let Statement::Select(mut s) = stmt else {
            return Err(EngineError::Unsupported(
                "prepare_select_streaming: not a SELECT".into(),
            ));
        };
        resolve_order_by_position(&mut s);
        reorder::reorder_joins_with(
            &mut s,
            &self.catalog,
            &self.statistics,
            self.env_cfg.plan_deterministic,
        );
        Ok(s)
    }

    /// Re-apply `rewrite_clock_calls` to a previously-prepared AST
    /// (cache-friendly: the cached AST's embedded clock literal gets
    /// re-pointed to current time without re-parsing).
    pub fn refresh_clock(&self, s: &mut spg_sql::ast::SelectStatement) {
        let now_micros = self.clock.map(|f| f());
        if now_micros.is_none() {
            return;
        }
        // Wrap as Statement::Select temporarily to reuse the public
        // walker; cheap (one enum tag manipulation).
        let mut stmt = Statement::Select(core::mem::take(s));
        rewrite_clock_calls(
            &mut stmt,
            now_micros,
            self.backslash_escapes,
            now_micros.map_or(0, |n| self.session_tz_offset_at(n)),
        );
        if let Statement::Select(rewritten) = stmt {
            *s = rewritten;
        }
    }

    /// v7.37.x (docker-fair SCALARSQ wire-overhead attack) — prepared
    /// SELECT that returns the full materialised `QueryResult` instead
    /// of driving an emit closure per row. The streaming variant is
    /// only a win when the engine can stream rows lazily (joined
    /// non-aggregate projection through `try_exec_joined_streaming`);
    /// for shapes that materialise inside the engine anyway (anything
    /// with a subquery — including the SCALARSQ shape — and most
    /// aggregates), the emit closure dispatch + cell_refs Vec
    /// management add ~25-50 µs / 100-row response for zero benefit.
    /// This API lets the caller skip the streaming wrapper entirely
    /// and iterate the result rows directly into the wire encoder.
    pub fn execute_readonly_select_prepared(
        &self,
        s: &spg_sql::ast::SelectStatement,
        cancel: CancelToken<'_>,
    ) -> Result<QueryResult, EngineError> {
        cancel.check()?;
        self.exec_select_cancel(s, cancel)
    }

    /// v7.37.42-arena Phase 2 — arena-aware streaming SELECT API.
    /// On SCALARSQ streaming-shape detection (`is_scalarsq_streaming_
    /// shape`), routes to `exec_scalarsq_streaming` and emits each
    /// projected row straight out of an arena-backed `bumpalo::Vec`
    /// scratch — no `Vec<Row<'static>>` ever materialises in the
    /// engine for this shape.
    ///
    /// Non-streaming shapes fall through to the generic
    /// `exec_select_cancel` materialised path and emit row-by-row
    /// off the returned `Vec<Row>`; callers stay shape-blind.
    ///
    /// Caller passes a `&'a Bump`; per-row projection scratch lives
    /// in that arena and drops in O(1) at the caller's
    /// `Bump::reset()` / scope end. This is the SPG equivalent of
    /// PG's per-query MessageContext / printtup pattern.
    ///
    /// The shape check is fast (~10 boolean field reads + items
    /// walk); calling on every prepared SELECT is fine.
    pub fn execute_readonly_select_with_arena<'a, F>(
        &self,
        s: &spg_sql::ast::SelectStatement,
        cancel: CancelToken<'_>,
        arena: &'a bumpalo::Bump,
        mut emit: F,
    ) -> Result<(Vec<spg_storage::ColumnSchema>, usize), EngineError>
    where
        F: FnMut(
            &[spg_storage::ColumnSchema],
            &[spg_storage::Value<'a>],
        ) -> Result<(), EngineError>,
    {
        cancel.check()?;
        // v7.39 (read01 round 57) — this path can short-circuit STRAIGHT into
        // the scalarsq streaming executor, below `exec_select_cancel` and its
        // gate. Check here too.
        self.acl_check_select(s)?;
        if crate::scalarsq_streaming::is_scalarsq_streaming_shape(s) {
            return self.exec_scalarsq_streaming(s, cancel, arena, emit);
        }
        // Generic fallback — same as `execute_readonly_select_prepared`
        // but adapted to the streaming-shape API's columns+row
        // callback signature. The arena isn't used here (cells are
        // owned `Value<'static>`); the win for the fallback shape
        // lands in later phases.
        let QueryResult::Rows { columns, rows } = self.exec_select_cancel(s, cancel)? else {
            return Err(EngineError::Unsupported(
                "execute_readonly_select_with_arena fallback got a non-Rows result".into(),
            ));
        };
        for (i, row) in rows.iter().enumerate() {
            // v7.37 (round 824) — the fourth copy of this loop, and the
            // fourth one missing a cancellation check. It cannot share
            // `emit_materialised` because its consumer takes columns and
            // values rather than a `StreamItem`, but it owes the same
            // guarantee: `SELECT id + 0 FROM big` lands here, and under a
            // 120ms timeout it delivered all 200000 rows in 400ms.
            if i.is_multiple_of(256) {
                cancel.check()?;
            }
            // `&[Value<'static>]` satisfies `&[Value<'a>]` via
            // covariance of `Cow<'a, str>` in `'a`.
            emit(&columns, &row.values)?;
        }
        let n = rows.len();
        Ok((columns, n))
    }

    pub fn execute_readonly_select_streaming_prepared<F>(
        &self,
        s: &spg_sql::ast::SelectStatement,
        cancel: CancelToken<'_>,
        mut emit: F,
    ) -> Result<usize, EngineError>
    where
        F: FnMut(crate::StreamItem<'_>) -> Result<(), EngineError>,
    {
        cancel.check()?;
        // v7.39 (read01 round 57) — same story: the joined-streaming shortcut
        // runs below `exec_select_cancel`.
        self.acl_check_select(s)?;
        if !crate::expr_tree_has_subquery(s)
            && let Some(n) = self.try_exec_joined_streaming(s, cancel, &mut emit)?
        {
            return Ok(n);
        }
        // v7.39 (round 564) — an index-only range emits straight through.
        // Below, the materialising path builds a `Vec<Row>` and this
        // function walks it once to borrow each cell back out; a profile
        // at 50k rows put a fifth of the connection thread's CPU on
        // building and dropping that vector alone.
        if let Some(n) = self.try_index_only_stream(s, &mut emit)? {
            return Ok(n);
        }
        let QueryResult::Rows { columns, rows } = self.exec_select_cancel(s, cancel)? else {
            return Err(EngineError::Unsupported(
                "streaming SELECT got a non-Rows result".into(),
            ));
        };
        crate::execute::emit_materialised(&columns, &rows, cancel, &mut emit)
    }

    pub fn execute_readonly_select_streaming<F>(
        &self,
        sql: &str,
        cancel: CancelToken<'_>,
        mut emit: F,
    ) -> Result<usize, EngineError>
    where
        F: FnMut(crate::StreamItem<'_>) -> Result<(), EngineError>,
    {
        cancel.check()?;
        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
        let now_micros = self.clock.map(|f| f());
        rewrite_clock_calls(
            &mut stmt,
            now_micros,
            self.backslash_escapes,
            now_micros.map_or(0, |n| self.session_tz_offset_at(n)),
        );
        let Statement::Select(mut s) = stmt else {
            return Err(EngineError::Unsupported(
                "execute_readonly_select_streaming: not a SELECT".into(),
            ));
        };
        resolve_order_by_position(&mut s);
        reorder::reorder_joins_with(
            &mut s,
            &self.catalog,
            &self.statistics,
            self.env_cfg.plan_deterministic,
        );
        // Streaming fast path: joined non-aggregate projection of
        // bound columns. Falls back to the materialising path inside
        // `try_exec_joined_streaming` returning None for any shape
        // that needs the full result (aggregate, ORDER BY, DISTINCT,
        // subqueries, etc.) — the caller's `Vec<Row<'static>>` round-trip
        // still wins because Engine::execute path keeps materialising.
        if !crate::expr_tree_has_subquery(&s)
            && let Some(n) = self.try_exec_joined_streaming(&s, cancel, &mut emit)?
        {
            return Ok(n);
        }
        // Fall back: materialise then iterate. Mirrors the bottom
        // half of `exec_select_streaming` (execute.rs) but at the
        // read path — no `&mut self`, no `current_tx` flip.
        let QueryResult::Rows { columns, rows } = self.exec_select_cancel(&s, cancel)? else {
            return Err(EngineError::Unsupported(
                "streaming SELECT got a non-Rows result".into(),
            ));
        };
        crate::execute::emit_materialised(&columns, &rows, cancel, &mut emit)
    }

    /// v4.5 — read path with cooperative cancellation. Token's
    /// `is_cancelled` is checked at the start (so a watchdog that
    /// already fired returns Cancelled immediately) and at row-loop
    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
    /// don't bother checking.
    pub fn execute_readonly_with_cancel(
        &self,
        sql: &str,
        cancel: CancelToken<'_>,
    ) -> Result<QueryResult, EngineError> {
        cancel.check()?;
        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
        let now_micros = self.clock.map(|f| f());
        rewrite_clock_calls(
            &mut stmt,
            now_micros,
            self.backslash_escapes,
            now_micros.map_or(0, |n| self.session_tz_offset_at(n)),
        );
        if let Statement::Select(s) = &mut stmt {
            resolve_order_by_position(s);
            // v6.2.3 — cost-based JOIN reorder (read path).
            // v7.38 元机制 D — gated on plan_deterministic so
            // regression tests pin a stable join order.
            reorder::reorder_joins_with(
                s,
                &self.catalog,
                &self.statistics,
                self.env_cfg.plan_deterministic,
            );
        }
        self.execute_readonly_stmt_with_cancel(stmt, cancel)
    }

    /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
    /// Internal helper shared by the SQL-string path
    /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
    /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
    /// Statement-level transforms (clock rewrite, ORDER BY position,
    /// JOIN reorder, placeholder substitution) are the caller's
    /// responsibility — this helper assumes the AST is already
    /// execution-ready. Writes / DDL hit
    /// [`EngineError::WriteRequired`] the same way the SQL path does.
    fn execute_readonly_stmt_with_cancel(
        &self,
        stmt: Statement,
        cancel: CancelToken<'_>,
    ) -> Result<QueryResult, EngineError> {
        // v7.39 (read01 round 57) — the read path takes the SAME privilege gate
        // as `execute`. Skipping it here would have made every SELECT a way
        // around the ACL: the server dispatches read-only statements down this
        // path, not through `execute`.
        self.acl_check_statement(&stmt)?;
        let result = match stmt {
            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
            Statement::ShowTables => Ok(self.exec_show_tables()),
            Statement::ShowDatabases => Ok(self.exec_show_databases()),
            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
            Statement::ShowStatus => Ok(self.exec_show_status()),
            Statement::ShowVariables => Ok(self.exec_show_variables()),
            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
            Statement::ShowColumns(table) => self.exec_show_columns(&table),
            Statement::ShowUsers => Ok(self.exec_show_users()),
            Statement::ShowPublications => Ok(self.exec_show_publications()),
            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
            )),
            Statement::Explain(e) => self.exec_explain(&e, cancel),
            _ => Err(EngineError::WriteRequired),
        };
        self.enforce_row_limit(result)
    }
}