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 rewrite_clock_calls(&mut stmt, now_micros);
157 if let Statement::Select(s) = &mut stmt {
158 expand_group_by_all(s);
159 resolve_order_by_position(s);
160 reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
161 }
162 Ok(stmt)
163 }
164
165 /// **v4.0 concurrency**: this is the entry point the server takes
166 /// under an `RwLock::read()` so multiple `SELECT` clients run in
167 /// parallel without serialising on a single mutex.
168 pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
169 self.execute_readonly_with_cancel(sql, CancelToken::none())
170 }
171
172 /// v7.37.x (SPGS PROJ wire encode tax) — read-path streaming
173 /// SELECT. Parses the SQL, applies the same statement-level
174 /// rewrites the read path does (`rewrite_clock_calls`,
175 /// `resolve_order_by_position`, `reorder::reorder_joins`), then
176 /// drives the streaming SELECT executor with the caller's emit
177 /// callback. For PROJ-shape SQLs (joined non-aggregate projection
178 /// of bound columns over thousands of rows) the engine produces
179 /// each row to the emit fn WITHOUT materialising the result into
180 /// `Vec<Row<'static>>` — the per-cell `.cloned()` and per-row
181 /// `Row::new(values)` disappear. On the 25 k-row PROJ shape
182 /// that's about 4 ms saved (one less full result allocation pass
183 /// at the engine output boundary).
184 ///
185 /// Returns the surviving row count emitted (post-WHERE,
186 /// post-LIMIT) for the `CommandComplete` tag. Non-SELECT
187 /// statements surface as `Unsupported` so the caller can fall
188 /// back to the materialising read path.
189 /// v7.37.x (docker-fair SCALARSQ wire-overhead attack) — prepared-
190 /// SelectStatement variant. Caller has already run
191 /// `parser::parse_statement_with` + `rewrite_clock_calls` +
192 /// `resolve_order_by_position` + `reorder::reorder_joins` (the
193 /// per-connection parse cache in spg-server's pgwire layer caches
194 /// the post-prepare AST and re-applies `rewrite_clock_calls` per
195 /// invocation since the clock value embedded in the AST drifts).
196 /// Otherwise identical to the SQL-string entry point.
197 pub fn prepare_select_streaming(
198 &self,
199 sql: &str,
200 ) -> Result<spg_sql::ast::SelectStatement, EngineError> {
201 let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
202 let now_micros = self.clock.map(|f| f());
203 rewrite_clock_calls(&mut stmt, now_micros);
204 let Statement::Select(mut s) = stmt else {
205 return Err(EngineError::Unsupported(
206 "prepare_select_streaming: not a SELECT".into(),
207 ));
208 };
209 resolve_order_by_position(&mut s);
210 reorder::reorder_joins(&mut s, &self.catalog, &self.statistics);
211 Ok(s)
212 }
213
214 /// Re-apply `rewrite_clock_calls` to a previously-prepared AST
215 /// (cache-friendly: the cached AST's embedded clock literal gets
216 /// re-pointed to current time without re-parsing).
217 pub fn refresh_clock(&self, s: &mut spg_sql::ast::SelectStatement) {
218 let now_micros = self.clock.map(|f| f());
219 if now_micros.is_none() {
220 return;
221 }
222 // Wrap as Statement::Select temporarily to reuse the public
223 // walker; cheap (one enum tag manipulation).
224 let mut stmt = Statement::Select(core::mem::take(s));
225 rewrite_clock_calls(&mut stmt, now_micros);
226 if let Statement::Select(rewritten) = stmt {
227 *s = rewritten;
228 }
229 }
230
231 /// v7.37.x (docker-fair SCALARSQ wire-overhead attack) — prepared
232 /// SELECT that returns the full materialised `QueryResult` instead
233 /// of driving an emit closure per row. The streaming variant is
234 /// only a win when the engine can stream rows lazily (joined
235 /// non-aggregate projection through `try_exec_joined_streaming`);
236 /// for shapes that materialise inside the engine anyway (anything
237 /// with a subquery — including the SCALARSQ shape — and most
238 /// aggregates), the emit closure dispatch + cell_refs Vec
239 /// management add ~25-50 µs / 100-row response for zero benefit.
240 /// This API lets the caller skip the streaming wrapper entirely
241 /// and iterate the result rows directly into the wire encoder.
242 pub fn execute_readonly_select_prepared(
243 &self,
244 s: &spg_sql::ast::SelectStatement,
245 cancel: CancelToken<'_>,
246 ) -> Result<QueryResult, EngineError> {
247 cancel.check()?;
248 self.exec_select_cancel(s, cancel)
249 }
250
251 /// v7.37.42-arena Phase 2 — arena-aware streaming SELECT API.
252 /// On SCALARSQ streaming-shape detection (`is_scalarsq_streaming_
253 /// shape`), routes to `exec_scalarsq_streaming` and emits each
254 /// projected row straight out of an arena-backed `bumpalo::Vec`
255 /// scratch — no `Vec<Row<'static>>` ever materialises in the
256 /// engine for this shape.
257 ///
258 /// Non-streaming shapes fall through to the generic
259 /// `exec_select_cancel` materialised path and emit row-by-row
260 /// off the returned `Vec<Row>`; callers stay shape-blind.
261 ///
262 /// Caller passes a `&'a Bump`; per-row projection scratch lives
263 /// in that arena and drops in O(1) at the caller's
264 /// `Bump::reset()` / scope end. This is the SPG equivalent of
265 /// PG's per-query MessageContext / printtup pattern.
266 ///
267 /// The shape check is fast (~10 boolean field reads + items
268 /// walk); calling on every prepared SELECT is fine.
269 pub fn execute_readonly_select_with_arena<'a, F>(
270 &self,
271 s: &spg_sql::ast::SelectStatement,
272 cancel: CancelToken<'_>,
273 arena: &'a bumpalo::Bump,
274 mut emit: F,
275 ) -> Result<(Vec<spg_storage::ColumnSchema>, usize), EngineError>
276 where
277 F: FnMut(
278 &[spg_storage::ColumnSchema],
279 &[spg_storage::Value<'a>],
280 ) -> Result<(), EngineError>,
281 {
282 cancel.check()?;
283 if crate::scalarsq_streaming::is_scalarsq_streaming_shape(s) {
284 return self.exec_scalarsq_streaming(s, cancel, arena, emit);
285 }
286 // Generic fallback — same as `execute_readonly_select_prepared`
287 // but adapted to the streaming-shape API's columns+row
288 // callback signature. The arena isn't used here (cells are
289 // owned `Value<'static>`); the win for the fallback shape
290 // lands in later phases.
291 let QueryResult::Rows { columns, rows } = self.exec_select_cancel(s, cancel)? else {
292 return Err(EngineError::Unsupported(
293 "execute_readonly_select_with_arena fallback got a non-Rows result".into(),
294 ));
295 };
296 for row in &rows {
297 // `&[Value<'static>]` satisfies `&[Value<'a>]` via
298 // covariance of `Cow<'a, str>` in `'a`.
299 emit(&columns, &row.values)?;
300 }
301 let n = rows.len();
302 Ok((columns, n))
303 }
304
305 pub fn execute_readonly_select_streaming_prepared<F>(
306 &self,
307 s: &spg_sql::ast::SelectStatement,
308 cancel: CancelToken<'_>,
309 mut emit: F,
310 ) -> Result<usize, EngineError>
311 where
312 F: FnMut(crate::StreamItem<'_>) -> Result<(), EngineError>,
313 {
314 cancel.check()?;
315 if !crate::expr_tree_has_subquery(s)
316 && let Some(n) = self.try_exec_joined_streaming(s, cancel, &mut emit)?
317 {
318 return Ok(n);
319 }
320 let QueryResult::Rows { columns, rows } = self.exec_select_cancel(s, cancel)? else {
321 return Err(EngineError::Unsupported(
322 "streaming SELECT got a non-Rows result".into(),
323 ));
324 };
325 emit(crate::StreamItem::Header(&columns))?;
326 let mut cell_refs: Vec<&Value> = Vec::with_capacity(columns.len());
327 for row in &rows {
328 cell_refs.clear();
329 for v in &row.values {
330 cell_refs.push(v);
331 }
332 emit(crate::StreamItem::Row(&cell_refs))?;
333 }
334 Ok(rows.len())
335 }
336
337 pub fn execute_readonly_select_streaming<F>(
338 &self,
339 sql: &str,
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 let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
348 let now_micros = self.clock.map(|f| f());
349 rewrite_clock_calls(&mut stmt, now_micros);
350 let Statement::Select(mut s) = stmt else {
351 return Err(EngineError::Unsupported(
352 "execute_readonly_select_streaming: not a SELECT".into(),
353 ));
354 };
355 resolve_order_by_position(&mut s);
356 reorder::reorder_joins(&mut s, &self.catalog, &self.statistics);
357 // Streaming fast path: joined non-aggregate projection of
358 // bound columns. Falls back to the materialising path inside
359 // `try_exec_joined_streaming` returning None for any shape
360 // that needs the full result (aggregate, ORDER BY, DISTINCT,
361 // subqueries, etc.) — the caller's `Vec<Row<'static>>` round-trip
362 // still wins because Engine::execute path keeps materialising.
363 if !crate::expr_tree_has_subquery(&s)
364 && let Some(n) = self.try_exec_joined_streaming(&s, cancel, &mut emit)?
365 {
366 return Ok(n);
367 }
368 // Fall back: materialise then iterate. Mirrors the bottom
369 // half of `exec_select_streaming` (execute.rs) but at the
370 // read path — no `&mut self`, no `current_tx` flip.
371 let QueryResult::Rows { columns, rows } = self.exec_select_cancel(&s, cancel)? else {
372 return Err(EngineError::Unsupported(
373 "streaming SELECT got a non-Rows result".into(),
374 ));
375 };
376 emit(crate::StreamItem::Header(&columns))?;
377 let mut cell_refs: Vec<&Value> = Vec::with_capacity(columns.len());
378 for row in &rows {
379 cell_refs.clear();
380 for v in &row.values {
381 cell_refs.push(v);
382 }
383 emit(crate::StreamItem::Row(&cell_refs))?;
384 }
385 Ok(rows.len())
386 }
387
388 /// v4.5 — read path with cooperative cancellation. Token's
389 /// `is_cancelled` is checked at the start (so a watchdog that
390 /// already fired returns Cancelled immediately) and at row-loop
391 /// checkpoints inside `exec_select`. SHOW paths are O(small) and
392 /// don't bother checking.
393 pub fn execute_readonly_with_cancel(
394 &self,
395 sql: &str,
396 cancel: CancelToken<'_>,
397 ) -> Result<QueryResult, EngineError> {
398 cancel.check()?;
399 let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
400 let now_micros = self.clock.map(|f| f());
401 rewrite_clock_calls(&mut stmt, now_micros);
402 if let Statement::Select(s) = &mut stmt {
403 resolve_order_by_position(s);
404 // v6.2.3 — cost-based JOIN reorder (read path).
405 reorder::reorder_joins(s, &self.catalog, &self.statistics);
406 }
407 self.execute_readonly_stmt_with_cancel(stmt, cancel)
408 }
409
410 /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
411 /// Internal helper shared by the SQL-string path
412 /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
413 /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
414 /// Statement-level transforms (clock rewrite, ORDER BY position,
415 /// JOIN reorder, placeholder substitution) are the caller's
416 /// responsibility — this helper assumes the AST is already
417 /// execution-ready. Writes / DDL hit
418 /// [`EngineError::WriteRequired`] the same way the SQL path does.
419 fn execute_readonly_stmt_with_cancel(
420 &self,
421 stmt: Statement,
422 cancel: CancelToken<'_>,
423 ) -> Result<QueryResult, EngineError> {
424 let result = match stmt {
425 Statement::Select(s) => self.exec_select_cancel(&s, cancel),
426 Statement::ShowTables => Ok(self.exec_show_tables()),
427 Statement::ShowDatabases => Ok(self.exec_show_databases()),
428 Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
429 Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
430 Statement::ShowStatus => Ok(self.exec_show_status()),
431 Statement::ShowVariables => Ok(self.exec_show_variables()),
432 Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
433 Statement::ShowColumns(table) => self.exec_show_columns(&table),
434 Statement::ShowUsers => Ok(self.exec_show_users()),
435 Statement::ShowPublications => Ok(self.exec_show_publications()),
436 Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
437 Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
438 "WAIT FOR WAL POSITION must be handled by the server layer".into(),
439 )),
440 Statement::Explain(e) => self.exec_explain(&e, cancel),
441 _ => Err(EngineError::WriteRequired),
442 };
443 self.enforce_row_limit(result)
444 }
445}