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}