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 let now_micros = self.clock.map(|f| f());
207 rewrite_clock_calls(
208 &mut stmt,
209 now_micros,
210 self.backslash_escapes,
211 now_micros.map_or(0, |n| self.session_tz_offset_at(n)),
212 );
213 let Statement::Select(mut s) = stmt else {
214 return Err(EngineError::Unsupported(
215 "prepare_select_streaming: not a SELECT".into(),
216 ));
217 };
218 resolve_order_by_position(&mut s);
219 reorder::reorder_joins_with(
220 &mut s,
221 &self.catalog,
222 &self.statistics,
223 self.env_cfg.plan_deterministic,
224 );
225 Ok(s)
226 }
227
228 /// Re-apply `rewrite_clock_calls` to a previously-prepared AST
229 /// (cache-friendly: the cached AST's embedded clock literal gets
230 /// re-pointed to current time without re-parsing).
231 pub fn refresh_clock(&self, s: &mut spg_sql::ast::SelectStatement) {
232 let now_micros = self.clock.map(|f| f());
233 if now_micros.is_none() {
234 return;
235 }
236 // Wrap as Statement::Select temporarily to reuse the public
237 // walker; cheap (one enum tag manipulation).
238 let mut stmt = Statement::Select(core::mem::take(s));
239 rewrite_clock_calls(
240 &mut stmt,
241 now_micros,
242 self.backslash_escapes,
243 now_micros.map_or(0, |n| self.session_tz_offset_at(n)),
244 );
245 if let Statement::Select(rewritten) = stmt {
246 *s = rewritten;
247 }
248 }
249
250 /// v7.37.x (docker-fair SCALARSQ wire-overhead attack) — prepared
251 /// SELECT that returns the full materialised `QueryResult` instead
252 /// of driving an emit closure per row. The streaming variant is
253 /// only a win when the engine can stream rows lazily (joined
254 /// non-aggregate projection through `try_exec_joined_streaming`);
255 /// for shapes that materialise inside the engine anyway (anything
256 /// with a subquery — including the SCALARSQ shape — and most
257 /// aggregates), the emit closure dispatch + cell_refs Vec
258 /// management add ~25-50 µs / 100-row response for zero benefit.
259 /// This API lets the caller skip the streaming wrapper entirely
260 /// and iterate the result rows directly into the wire encoder.
261 pub fn execute_readonly_select_prepared(
262 &self,
263 s: &spg_sql::ast::SelectStatement,
264 cancel: CancelToken<'_>,
265 ) -> Result<QueryResult, EngineError> {
266 cancel.check()?;
267 self.exec_select_cancel(s, cancel)
268 }
269
270 /// v7.37.42-arena Phase 2 — arena-aware streaming SELECT API.
271 /// On SCALARSQ streaming-shape detection (`is_scalarsq_streaming_
272 /// shape`), routes to `exec_scalarsq_streaming` and emits each
273 /// projected row straight out of an arena-backed `bumpalo::Vec`
274 /// scratch — no `Vec<Row<'static>>` ever materialises in the
275 /// engine for this shape.
276 ///
277 /// Non-streaming shapes fall through to the generic
278 /// `exec_select_cancel` materialised path and emit row-by-row
279 /// off the returned `Vec<Row>`; callers stay shape-blind.
280 ///
281 /// Caller passes a `&'a Bump`; per-row projection scratch lives
282 /// in that arena and drops in O(1) at the caller's
283 /// `Bump::reset()` / scope end. This is the SPG equivalent of
284 /// PG's per-query MessageContext / printtup pattern.
285 ///
286 /// The shape check is fast (~10 boolean field reads + items
287 /// walk); calling on every prepared SELECT is fine.
288 pub fn execute_readonly_select_with_arena<'a, F>(
289 &self,
290 s: &spg_sql::ast::SelectStatement,
291 cancel: CancelToken<'_>,
292 arena: &'a bumpalo::Bump,
293 mut emit: F,
294 ) -> Result<(Vec<spg_storage::ColumnSchema>, usize), EngineError>
295 where
296 F: FnMut(
297 &[spg_storage::ColumnSchema],
298 &[spg_storage::Value<'a>],
299 ) -> Result<(), EngineError>,
300 {
301 cancel.check()?;
302 // v7.39 (read01 round 57) — this path can short-circuit STRAIGHT into
303 // the scalarsq streaming executor, below `exec_select_cancel` and its
304 // gate. Check here too.
305 self.acl_check_select(s)?;
306 if crate::scalarsq_streaming::is_scalarsq_streaming_shape(s) {
307 return self.exec_scalarsq_streaming(s, cancel, arena, emit);
308 }
309 // Generic fallback — same as `execute_readonly_select_prepared`
310 // but adapted to the streaming-shape API's columns+row
311 // callback signature. The arena isn't used here (cells are
312 // owned `Value<'static>`); the win for the fallback shape
313 // lands in later phases.
314 let QueryResult::Rows { columns, rows } = self.exec_select_cancel(s, cancel)? else {
315 return Err(EngineError::Unsupported(
316 "execute_readonly_select_with_arena fallback got a non-Rows result".into(),
317 ));
318 };
319 for (i, row) in rows.iter().enumerate() {
320 // v7.37 (round 824) — the fourth copy of this loop, and the
321 // fourth one missing a cancellation check. It cannot share
322 // `emit_materialised` because its consumer takes columns and
323 // values rather than a `StreamItem`, but it owes the same
324 // guarantee: `SELECT id + 0 FROM big` lands here, and under a
325 // 120ms timeout it delivered all 200000 rows in 400ms.
326 if i.is_multiple_of(256) {
327 cancel.check()?;
328 }
329 // `&[Value<'static>]` satisfies `&[Value<'a>]` via
330 // covariance of `Cow<'a, str>` in `'a`.
331 emit(&columns, &row.values)?;
332 }
333 let n = rows.len();
334 Ok((columns, n))
335 }
336
337 pub fn execute_readonly_select_streaming_prepared<F>(
338 &self,
339 s: &spg_sql::ast::SelectStatement,
340 cancel: CancelToken<'_>,
341 mut emit: F,
342 ) -> Result<usize, EngineError>
343 where
344 F: FnMut(crate::StreamItem<'_>) -> Result<(), EngineError>,
345 {
346 cancel.check()?;
347 // v7.39 (read01 round 57) — same story: the joined-streaming shortcut
348 // runs below `exec_select_cancel`.
349 self.acl_check_select(s)?;
350 if !crate::expr_tree_has_subquery(s)
351 && let Some(n) = self.try_exec_joined_streaming(s, cancel, &mut emit)?
352 {
353 return Ok(n);
354 }
355 // v7.39 (round 564) — an index-only range emits straight through.
356 // Below, the materialising path builds a `Vec<Row>` and this
357 // function walks it once to borrow each cell back out; a profile
358 // at 50k rows put a fifth of the connection thread's CPU on
359 // building and dropping that vector alone.
360 if let Some(n) = self.try_index_only_stream(s, &mut emit)? {
361 return Ok(n);
362 }
363 let QueryResult::Rows { columns, rows } = self.exec_select_cancel(s, cancel)? else {
364 return Err(EngineError::Unsupported(
365 "streaming SELECT got a non-Rows result".into(),
366 ));
367 };
368 crate::execute::emit_materialised(&columns, &rows, cancel, &mut emit)
369 }
370
371 pub fn execute_readonly_select_streaming<F>(
372 &self,
373 sql: &str,
374 cancel: CancelToken<'_>,
375 mut emit: F,
376 ) -> Result<usize, EngineError>
377 where
378 F: FnMut(crate::StreamItem<'_>) -> Result<(), EngineError>,
379 {
380 cancel.check()?;
381 let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
382 let now_micros = self.clock.map(|f| f());
383 rewrite_clock_calls(
384 &mut stmt,
385 now_micros,
386 self.backslash_escapes,
387 now_micros.map_or(0, |n| self.session_tz_offset_at(n)),
388 );
389 let Statement::Select(mut s) = stmt else {
390 return Err(EngineError::Unsupported(
391 "execute_readonly_select_streaming: not a SELECT".into(),
392 ));
393 };
394 resolve_order_by_position(&mut s);
395 reorder::reorder_joins_with(
396 &mut s,
397 &self.catalog,
398 &self.statistics,
399 self.env_cfg.plan_deterministic,
400 );
401 // Streaming fast path: joined non-aggregate projection of
402 // bound columns. Falls back to the materialising path inside
403 // `try_exec_joined_streaming` returning None for any shape
404 // that needs the full result (aggregate, ORDER BY, DISTINCT,
405 // subqueries, etc.) — the caller's `Vec<Row<'static>>` round-trip
406 // still wins because Engine::execute path keeps materialising.
407 if !crate::expr_tree_has_subquery(&s)
408 && let Some(n) = self.try_exec_joined_streaming(&s, cancel, &mut emit)?
409 {
410 return Ok(n);
411 }
412 // Fall back: materialise then iterate. Mirrors the bottom
413 // half of `exec_select_streaming` (execute.rs) but at the
414 // read path — no `&mut self`, no `current_tx` flip.
415 let QueryResult::Rows { columns, rows } = self.exec_select_cancel(&s, cancel)? else {
416 return Err(EngineError::Unsupported(
417 "streaming SELECT got a non-Rows result".into(),
418 ));
419 };
420 crate::execute::emit_materialised(&columns, &rows, cancel, &mut emit)
421 }
422
423 /// v4.5 — read path with cooperative cancellation. Token's
424 /// `is_cancelled` is checked at the start (so a watchdog that
425 /// already fired returns Cancelled immediately) and at row-loop
426 /// checkpoints inside `exec_select`. SHOW paths are O(small) and
427 /// don't bother checking.
428 pub fn execute_readonly_with_cancel(
429 &self,
430 sql: &str,
431 cancel: CancelToken<'_>,
432 ) -> Result<QueryResult, EngineError> {
433 cancel.check()?;
434 let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
435 let now_micros = self.clock.map(|f| f());
436 rewrite_clock_calls(
437 &mut stmt,
438 now_micros,
439 self.backslash_escapes,
440 now_micros.map_or(0, |n| self.session_tz_offset_at(n)),
441 );
442 if let Statement::Select(s) = &mut stmt {
443 resolve_order_by_position(s);
444 // v6.2.3 — cost-based JOIN reorder (read path).
445 // v7.38 元机制 D — gated on plan_deterministic so
446 // regression tests pin a stable join order.
447 reorder::reorder_joins_with(
448 s,
449 &self.catalog,
450 &self.statistics,
451 self.env_cfg.plan_deterministic,
452 );
453 }
454 self.execute_readonly_stmt_with_cancel(stmt, cancel)
455 }
456
457 /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
458 /// Internal helper shared by the SQL-string path
459 /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
460 /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
461 /// Statement-level transforms (clock rewrite, ORDER BY position,
462 /// JOIN reorder, placeholder substitution) are the caller's
463 /// responsibility — this helper assumes the AST is already
464 /// execution-ready. Writes / DDL hit
465 /// [`EngineError::WriteRequired`] the same way the SQL path does.
466 fn execute_readonly_stmt_with_cancel(
467 &self,
468 stmt: Statement,
469 cancel: CancelToken<'_>,
470 ) -> Result<QueryResult, EngineError> {
471 // v7.39 (read01 round 57) — the read path takes the SAME privilege gate
472 // as `execute`. Skipping it here would have made every SELECT a way
473 // around the ACL: the server dispatches read-only statements down this
474 // path, not through `execute`.
475 self.acl_check_statement(&stmt)?;
476 let result = match stmt {
477 Statement::Select(s) => self.exec_select_cancel(&s, cancel),
478 Statement::ShowTables => Ok(self.exec_show_tables()),
479 Statement::ShowDatabases => Ok(self.exec_show_databases()),
480 Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
481 Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
482 Statement::ShowStatus => Ok(self.exec_show_status()),
483 Statement::ShowVariables => Ok(self.exec_show_variables()),
484 Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
485 Statement::ShowColumns(table) => self.exec_show_columns(&table),
486 Statement::ShowUsers => Ok(self.exec_show_users()),
487 Statement::ShowPublications => Ok(self.exec_show_publications()),
488 Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
489 Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
490 "WAIT FOR WAL POSITION must be handled by the server layer".into(),
491 )),
492 Statement::Explain(e) => self.exec_explain(&e, cancel),
493 _ => Err(EngineError::WriteRequired),
494 };
495 self.enforce_row_limit(result)
496 }
497}