Skip to main content

spg_engine/
readonly.rs

1//! Read-only / snapshot execution, split out of `lib.rs` (lib.rs split
2//! 18). Two entry families share one module: the live read path
3//! (`execute_readonly` / `_with_cancel`, taken by the server under an
4//! `RwLock::read()` so SELECTs run in parallel) and the snapshot path
5//! (`execute_readonly_on_snapshot` / the prepared + describe variants /
6//! `is_readonly_sql` / `prepare_on_snapshot`), which run against a
7//! `CatalogSnapshot` without borrowing the engine. Both reject DDL/DML
8//! with `WriteRequired` and route SELECT / SHOW / EXPLAIN to the same
9//! domain handlers as the write path. Whole `impl Engine` methods; the
10//! public surface is unchanged, and `enforce_row_limit` stays in the
11//! crate root (shared with `execute.rs`, reached via self).
12
13use alloc::vec::Vec;
14
15use spg_sql::ast::Statement;
16use spg_sql::parser::{self, ParseError};
17use spg_storage::{ColumnSchema, Value};
18
19use crate::describe;
20use crate::{
21    CancelToken, CatalogSnapshot, Engine, EngineError, QueryResult, expand_group_by_all, reorder,
22    resolve_order_by_position, rewrite_clock_calls, substitute_placeholders,
23};
24
25impl Engine {
26    /// v7.11.1 — execute a read-only SQL statement against a
27    /// `CatalogSnapshot` without touching this engine. Same
28    /// semantics as `execute_readonly` but parameterised on the
29    /// snapshot's catalog. Reject DDL/DML the same way
30    /// `execute_readonly` does. Static-on-Self so the caller can
31    /// dispatch without holding an `Engine` borrow alongside the
32    /// snapshot.
33    pub fn execute_readonly_on_snapshot(
34        snapshot: &CatalogSnapshot,
35        sql: &str,
36    ) -> Result<QueryResult, EngineError> {
37        Self::execute_readonly_on_snapshot_with_cancel(snapshot, sql, CancelToken::none())
38    }
39
40    /// v7.11.1 — `execute_readonly_on_snapshot` with cooperative
41    /// cancellation. Builds a transient `Engine` over the snapshot
42    /// state, runs `execute_readonly_with_cancel`, drops. The
43    /// transient engine is cheap to construct (no I/O; everything
44    /// is just struct moves) and lets the existing read path stay
45    /// untouched.
46    pub fn execute_readonly_on_snapshot_with_cancel(
47        snapshot: &CatalogSnapshot,
48        sql: &str,
49        cancel: CancelToken<'_>,
50    ) -> Result<QueryResult, EngineError> {
51        let transient = Engine {
52            catalog: snapshot.catalog.clone(),
53            statistics: snapshot.statistics.clone(),
54            clock: snapshot.clock,
55            max_query_rows: snapshot.max_query_rows,
56            ..Engine::default()
57        };
58        transient.execute_readonly_with_cancel(sql, cancel)
59    }
60
61    /// v7.18 — execute a previously-prepared `Statement` against a
62    /// `CatalogSnapshot` in read-only mode. Mirror of
63    /// [`Engine::execute_prepared`] for the fan-out read path:
64    /// substitutes `Expr::Placeholder(n)` nodes from `params`, then
65    /// dispatches through [`Engine::execute_readonly_stmt_with_cancel`]
66    /// (writes / DDL hit `EngineError::WriteRequired`). Static-on-Self
67    /// so multiple readonly threads can dispatch against the same
68    /// snapshot concurrently without an `Engine` borrow.
69    ///
70    /// **Schema drift contract**. The `Statement` was prepared against
71    /// some prior catalog. If the snapshot's catalog has since
72    /// diverged (DDL renamed / dropped a referenced column / table),
73    /// execution surfaces the normal `EngineError` — same shape as
74    /// PG's "cached plan must not change result type". Caller decides
75    /// whether to re-prepare; engine does NOT auto-retry.
76    pub fn execute_readonly_prepared_on_snapshot(
77        snapshot: &CatalogSnapshot,
78        stmt: Statement,
79        params: &[Value<'static>],
80    ) -> Result<QueryResult, EngineError> {
81        Self::execute_readonly_prepared_on_snapshot_with_cancel(
82            snapshot,
83            stmt,
84            params,
85            CancelToken::none(),
86        )
87    }
88
89    /// v7.18 — cancellable variant of
90    /// [`Engine::execute_readonly_prepared_on_snapshot`].
91    pub fn execute_readonly_prepared_on_snapshot_with_cancel(
92        snapshot: &CatalogSnapshot,
93        mut stmt: Statement,
94        params: &[Value<'static>],
95        cancel: CancelToken<'_>,
96    ) -> Result<QueryResult, EngineError> {
97        cancel.check()?;
98        substitute_placeholders(&mut stmt, params)?;
99        let transient = Engine {
100            catalog: snapshot.catalog.clone(),
101            statistics: snapshot.statistics.clone(),
102            clock: snapshot.clock,
103            max_query_rows: snapshot.max_query_rows,
104            ..Engine::default()
105        };
106        transient.execute_readonly_stmt_with_cancel(stmt, cancel)
107    }
108
109    /// v7.18 — describe a prepared `Statement` against a
110    /// `CatalogSnapshot`. Same `(parameter_oids, output_columns)`
111    /// shape as [`Engine::describe_prepared`]; resolves names
112    /// against the snapshot's catalog instead of `self`. Pure
113    /// function — no engine state read.
114    pub fn describe_prepared_on_snapshot(
115        snapshot: &CatalogSnapshot,
116        stmt: &Statement,
117    ) -> (Vec<u32>, Vec<ColumnSchema>) {
118        describe::describe_prepared(stmt, &snapshot.catalog)
119    }
120
121    /// v7.18 — does this SQL string classify as read-only? Parses
122    /// `sql` with the engine parser and consults
123    /// `Statement::is_readonly()`. A parse error returns `false`
124    /// (route to the writer path so the user sees the canonical
125    /// parse error from the writer's simple-query dispatch).
126    /// Static-on-Self so the spg-sqlx connection layer can ask
127    /// without an `Engine` borrow.
128    #[must_use]
129    pub fn is_readonly_sql(sql: &str) -> bool {
130        parser::parse_statement(sql)
131            .as_ref()
132            .map(spg_sql::ast::Statement::is_readonly)
133            .unwrap_or(false)
134    }
135
136    /// v7.18 — parse + plan a SQL string against a
137    /// `CatalogSnapshot`. Mirror of [`Engine::prepare`] for the
138    /// readonly fan-out path: applies the same prepare-time
139    /// transforms (clock rewrite, `GROUP BY ALL` expansion, ORDER
140    /// BY position resolve, cost-based JOIN reorder) but resolves
141    /// catalog + statistics against the snapshot, not a live
142    /// engine. Static-on-Self — `AsyncReadHandle::prepare` calls
143    /// this without taking the writer lock so multiple read
144    /// handles can prepare concurrently against frozen views.
145    ///
146    /// # Errors
147    /// Propagates [`ParseError`] from the parser. Schema
148    /// validation deferred to execute time, same as
149    /// [`Engine::prepare`].
150    pub fn prepare_on_snapshot(
151        snapshot: &CatalogSnapshot,
152        sql: &str,
153    ) -> Result<Statement, ParseError> {
154        let mut stmt = parser::parse_statement(sql)?;
155        let now_micros = snapshot.clock.map(|f| f());
156        // A snapshot carries no session, so PG's reading — the stricter
157        // one — is the honest default here.
158        // A snapshot carries no session, so there is no zone to read the
159        // local-clock family in — UTC, as before.
160        rewrite_clock_calls(&mut stmt, now_micros, false, 0);
161        if let Statement::Select(s) = &mut stmt {
162            expand_group_by_all(s);
163            resolve_order_by_position(s);
164            reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
165        }
166        Ok(stmt)
167    }
168
169    /// **v4.0 concurrency**: this is the entry point the server takes
170    /// under an `RwLock::read()` so multiple `SELECT` clients run in
171    /// parallel without serialising on a single mutex.
172    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
173        self.execute_readonly_with_cancel(sql, CancelToken::none())
174    }
175
176    /// v7.37.x (SPGS PROJ wire encode tax) — read-path streaming
177    /// SELECT. Parses the SQL, applies the same statement-level
178    /// rewrites the read path does (`rewrite_clock_calls`,
179    /// `resolve_order_by_position`, `reorder::reorder_joins`), then
180    /// drives the streaming SELECT executor with the caller's emit
181    /// callback. For PROJ-shape SQLs (joined non-aggregate projection
182    /// of bound columns over thousands of rows) the engine produces
183    /// each row to the emit fn WITHOUT materialising the result into
184    /// `Vec<Row<'static>>` — the per-cell `.cloned()` and per-row
185    /// `Row::new(values)` disappear. On the 25 k-row PROJ shape
186    /// that's about 4 ms saved (one less full result allocation pass
187    /// at the engine output boundary).
188    ///
189    /// Returns the surviving row count emitted (post-WHERE,
190    /// post-LIMIT) for the `CommandComplete` tag. Non-SELECT
191    /// statements surface as `Unsupported` so the caller can fall
192    /// back to the materialising read path.
193    /// v7.37.x (docker-fair SCALARSQ wire-overhead attack) — prepared-
194    /// SelectStatement variant. Caller has already run
195    /// `parser::parse_statement_with` + `rewrite_clock_calls` +
196    /// `resolve_order_by_position` + `reorder::reorder_joins` (the
197    /// per-connection parse cache in spg-server's pgwire layer caches
198    /// the post-prepare AST and re-applies `rewrite_clock_calls` per
199    /// invocation since the clock value embedded in the AST drifts).
200    /// Otherwise identical to the SQL-string entry point.
201    pub fn prepare_select_streaming(
202        &self,
203        sql: &str,
204    ) -> Result<spg_sql::ast::SelectStatement, EngineError> {
205        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
206        // r1043 — the shared pre-pass. This was the third copy of the
207        // list; see `Engine::preprocess`.
208        self.preprocess(&mut stmt);
209        let Statement::Select(s) = stmt else {
210            return Err(EngineError::Unsupported(
211                "prepare_select_streaming: not a SELECT".into(),
212            ));
213        };
214        Ok(s)
215    }
216
217    /// Re-apply `rewrite_clock_calls` to a previously-prepared AST
218    /// (cache-friendly: the cached AST's embedded clock literal gets
219    /// re-pointed to current time without re-parsing).
220    pub fn refresh_clock(&self, s: &mut spg_sql::ast::SelectStatement) {
221        let now_micros = self.clock.map(|f| f());
222        if now_micros.is_none() {
223            return;
224        }
225        // Wrap as Statement::Select temporarily to reuse the public
226        // walker; cheap (one enum tag manipulation).
227        let mut stmt = Statement::Select(core::mem::take(s));
228        rewrite_clock_calls(
229            &mut stmt,
230            now_micros,
231            self.backslash_escapes,
232            now_micros.map_or(0, |n| self.session_tz_offset_at(n)),
233        );
234        if let Statement::Select(rewritten) = stmt {
235            *s = rewritten;
236        }
237    }
238
239    /// v7.37.x (docker-fair SCALARSQ wire-overhead attack) — prepared
240    /// SELECT that returns the full materialised `QueryResult` instead
241    /// of driving an emit closure per row. The streaming variant is
242    /// only a win when the engine can stream rows lazily (joined
243    /// non-aggregate projection through `try_exec_joined_streaming`);
244    /// for shapes that materialise inside the engine anyway (anything
245    /// with a subquery — including the SCALARSQ shape — and most
246    /// aggregates), the emit closure dispatch + cell_refs Vec
247    /// management add ~25-50 µs / 100-row response for zero benefit.
248    /// This API lets the caller skip the streaming wrapper entirely
249    /// and iterate the result rows directly into the wire encoder.
250    pub fn execute_readonly_select_prepared(
251        &self,
252        s: &spg_sql::ast::SelectStatement,
253        cancel: CancelToken<'_>,
254    ) -> Result<QueryResult, EngineError> {
255        cancel.check()?;
256        self.exec_select_cancel(s, cancel)
257    }
258
259    /// v7.37.42-arena Phase 2 — arena-aware streaming SELECT API.
260    /// On SCALARSQ streaming-shape detection (`is_scalarsq_streaming_
261    /// shape`), routes to `exec_scalarsq_streaming` and emits each
262    /// projected row straight out of an arena-backed `bumpalo::Vec`
263    /// scratch — no `Vec<Row<'static>>` ever materialises in the
264    /// engine for this shape.
265    ///
266    /// Non-streaming shapes fall through to the generic
267    /// `exec_select_cancel` materialised path and emit row-by-row
268    /// off the returned `Vec<Row>`; callers stay shape-blind.
269    ///
270    /// Caller passes a `&'a Bump`; per-row projection scratch lives
271    /// in that arena and drops in O(1) at the caller's
272    /// `Bump::reset()` / scope end. This is the SPG equivalent of
273    /// PG's per-query MessageContext / printtup pattern.
274    ///
275    /// The shape check is fast (~10 boolean field reads + items
276    /// walk); calling on every prepared SELECT is fine.
277    pub fn execute_readonly_select_with_arena<'a, F>(
278        &self,
279        s: &spg_sql::ast::SelectStatement,
280        cancel: CancelToken<'_>,
281        arena: &'a bumpalo::Bump,
282        mut emit: F,
283    ) -> Result<(Vec<spg_storage::ColumnSchema>, usize), EngineError>
284    where
285        F: FnMut(
286            &[spg_storage::ColumnSchema],
287            &[spg_storage::Value<'a>],
288        ) -> Result<(), EngineError>,
289    {
290        cancel.check()?;
291        // v7.39 (read01 round 57) — this path can short-circuit STRAIGHT into
292        // the scalarsq streaming executor, below `exec_select_cancel` and its
293        // gate. Check here too.
294        self.acl_check_select(s)?;
295        if crate::scalarsq_streaming::is_scalarsq_streaming_shape(s) {
296            return self.exec_scalarsq_streaming(s, cancel, arena, emit);
297        }
298        // Generic fallback — same as `execute_readonly_select_prepared`
299        // but adapted to the streaming-shape API's columns+row
300        // callback signature. The arena isn't used here (cells are
301        // owned `Value<'static>`); the win for the fallback shape
302        // lands in later phases.
303        let QueryResult::Rows { columns, rows } = self.exec_select_cancel(s, cancel)? else {
304            return Err(EngineError::Unsupported(
305                "execute_readonly_select_with_arena fallback got a non-Rows result".into(),
306            ));
307        };
308        for (i, row) in rows.iter().enumerate() {
309            // v7.37 (round 824) — the fourth copy of this loop, and the
310            // fourth one missing a cancellation check. It cannot share
311            // `emit_materialised` because its consumer takes columns and
312            // values rather than a `StreamItem`, but it owes the same
313            // guarantee: `SELECT id + 0 FROM big` lands here, and under a
314            // 120ms timeout it delivered all 200000 rows in 400ms.
315            if i.is_multiple_of(256) {
316                cancel.check()?;
317            }
318            // `&[Value<'static>]` satisfies `&[Value<'a>]` via
319            // covariance of `Cow<'a, str>` in `'a`.
320            emit(&columns, &row.values)?;
321        }
322        let n = rows.len();
323        Ok((columns, n))
324    }
325
326    pub fn execute_readonly_select_streaming_prepared<F>(
327        &self,
328        s: &spg_sql::ast::SelectStatement,
329        cancel: CancelToken<'_>,
330        mut emit: F,
331    ) -> Result<usize, EngineError>
332    where
333        F: FnMut(crate::StreamItem<'_>) -> Result<(), EngineError>,
334    {
335        cancel.check()?;
336        // v7.39 (read01 round 57) — same story: the joined-streaming shortcut
337        // runs below `exec_select_cancel`.
338        self.acl_check_select(s)?;
339        if !crate::expr_tree_has_subquery(s)
340            && let Some(n) = self.try_exec_joined_streaming(s, cancel, &mut emit)?
341        {
342            return Ok(n);
343        }
344        // v7.39 (round 564) — an index-only range emits straight through.
345        // Below, the materialising path builds a `Vec<Row>` and this
346        // function walks it once to borrow each cell back out; a profile
347        // at 50k rows put a fifth of the connection thread's CPU on
348        // building and dropping that vector alone.
349        if let Some(n) = self.try_index_only_stream(s, &mut emit)? {
350            return Ok(n);
351        }
352        let QueryResult::Rows { columns, rows } = self.exec_select_cancel(s, cancel)? else {
353            return Err(EngineError::Unsupported(
354                "streaming SELECT got a non-Rows result".into(),
355            ));
356        };
357        crate::execute::emit_materialised(&columns, &rows, cancel, &mut emit)
358    }
359
360    pub fn execute_readonly_select_streaming<F>(
361        &self,
362        sql: &str,
363        cancel: CancelToken<'_>,
364        mut emit: F,
365    ) -> Result<usize, EngineError>
366    where
367        F: FnMut(crate::StreamItem<'_>) -> Result<(), EngineError>,
368    {
369        cancel.check()?;
370        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
371        // r1043 — the shared pre-pass. THIS is the route every autocommit
372        // SELECT takes over the wire, and it was the copy that mattered:
373        // `WHERE b = decode(lpad(to_hex(7),16,'0'),'hex')` came through
374        // here unfolded and cost 198 ms against 0.013 ms for the same
375        // statement's `EXPLAIN ANALYZE` on the same connection, because
376        // EXPLAIN went through `prepare` and the query did not.
377        self.preprocess(&mut stmt);
378        let Statement::Select(s) = stmt else {
379            return Err(EngineError::Unsupported(
380                "execute_readonly_select_streaming: not a SELECT".into(),
381            ));
382        };
383        // Streaming fast path: joined non-aggregate projection of
384        // bound columns. Falls back to the materialising path inside
385        // `try_exec_joined_streaming` returning None for any shape
386        // that needs the full result (aggregate, ORDER BY, DISTINCT,
387        // subqueries, etc.) — the caller's `Vec<Row<'static>>` round-trip
388        // still wins because Engine::execute path keeps materialising.
389        if !crate::expr_tree_has_subquery(&s)
390            && let Some(n) = self.try_exec_joined_streaming(&s, cancel, &mut emit)?
391        {
392            return Ok(n);
393        }
394        // Fall back: materialise then iterate. Mirrors the bottom
395        // half of `exec_select_streaming` (execute.rs) but at the
396        // read path — no `&mut self`, no `current_tx` flip.
397        let QueryResult::Rows { columns, rows } = self.exec_select_cancel(&s, cancel)? else {
398            return Err(EngineError::Unsupported(
399                "streaming SELECT got a non-Rows result".into(),
400            ));
401        };
402        crate::execute::emit_materialised(&columns, &rows, cancel, &mut emit)
403    }
404
405    /// v4.5 — read path with cooperative cancellation. Token's
406    /// `is_cancelled` is checked at the start (so a watchdog that
407    /// already fired returns Cancelled immediately) and at row-loop
408    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
409    /// don't bother checking.
410    pub fn execute_readonly_with_cancel(
411        &self,
412        sql: &str,
413        cancel: CancelToken<'_>,
414    ) -> Result<QueryResult, EngineError> {
415        cancel.check()?;
416        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
417        // r1043 — the SAME pre-pass `prepare` runs. This path had its own
418        // copy of the list, one pass short of it, and every autocommit
419        // SELECT over the wire comes through here: a plan `EXPLAIN`
420        // described was not the plan this ran.
421        self.preprocess(&mut stmt);
422        self.execute_readonly_stmt_with_cancel(stmt, cancel)
423    }
424
425    /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
426    /// Internal helper shared by the SQL-string path
427    /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
428    /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
429    /// Statement-level transforms (clock rewrite, ORDER BY position,
430    /// JOIN reorder, placeholder substitution) are the caller's
431    /// responsibility — this helper assumes the AST is already
432    /// execution-ready. Writes / DDL hit
433    /// [`EngineError::WriteRequired`] the same way the SQL path does.
434    fn execute_readonly_stmt_with_cancel(
435        &self,
436        stmt: Statement,
437        cancel: CancelToken<'_>,
438    ) -> Result<QueryResult, EngineError> {
439        // v7.39 (read01 round 57) — the read path takes the SAME privilege gate
440        // as `execute`. Skipping it here would have made every SELECT a way
441        // around the ACL: the server dispatches read-only statements down this
442        // path, not through `execute`.
443        self.acl_check_statement(&stmt)?;
444        let result = match stmt {
445            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
446            Statement::ShowTables => Ok(self.exec_show_tables()),
447            Statement::ShowDatabases => Ok(self.exec_show_databases()),
448            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
449            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
450            Statement::ShowStatus => Ok(self.exec_show_status()),
451            Statement::ShowVariables => Ok(self.exec_show_variables()),
452            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
453            Statement::ShowColumns(table) => self.exec_show_columns(&table),
454            Statement::ShowUsers => Ok(self.exec_show_users()),
455            Statement::ShowPublications => Ok(self.exec_show_publications()),
456            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
457            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
458                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
459            )),
460            Statement::Explain(e) => self.exec_explain(&e, cancel),
461            _ => Err(EngineError::WriteRequired),
462        };
463        self.enforce_row_limit(result)
464    }
465}