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