powdb_query/executor/mod.rs
1//! PowDB query executor.
2
3// Submodules that don't use macros defined in this file.
4mod compiled;
5mod eval;
6pub mod mem_budget;
7mod statement;
8#[cfg(feature = "testing")]
9pub use statement::StatementCommitPhase;
10
11use crate::ast::*;
12use crate::canonicalize::canonicalize_with_form;
13use crate::plan::*;
14use crate::plan_cache::PlanCache;
15use crate::planner;
16use crate::result::{QueryError, QueryResult};
17use powdb_storage::catalog::Catalog;
18use powdb_storage::row::{decode_row, RowLayout, ROW_MAGIC, ROW_PREFIX_SIZE};
19use powdb_storage::types::*;
20use powdb_storage::view::ViewRegistry;
21pub use powdb_storage::wal::{WalDurabilityTicket, WalSyncMode};
22
23use std::io;
24use std::path::Path;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{Arc, Mutex};
27use std::time::Instant;
28use tracing::{error, info, warn, Level};
29
30use self::compiled::*;
31use self::eval::*;
32
33/// Legacy sentinel string constant — kept for backward compatibility with
34/// any external code matching on the string representation. New code should
35/// match on `QueryError::ReadonlyNeedsWrite` directly.
36pub const READONLY_NEEDS_WRITE: &str = "__POWDB_READONLY_NEEDS_WRITE__";
37
38/// Return the byte offset where the row body starts.
39///
40/// v0.5 rows begin with the `PROW` magic/version prefix. Legacy rows start
41/// directly with the row body. Raw executor fast paths must add this base
42/// before reading body-relative bitmap/data offsets.
43#[inline]
44pub(crate) fn row_body_base(row: &[u8]) -> usize {
45 if row.len() >= ROW_PREFIX_SIZE && &row[0..4] == ROW_MAGIC {
46 ROW_PREFIX_SIZE
47 } else {
48 0
49 }
50}
51
52/// Query frontend dialect. PowQL remains the default/native dialect; SQL is
53/// an explicit frontend that lowers to the same AST before planning.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum QueryDialect {
56 PowQL,
57 Sql,
58}
59
60/// Plan cache capacity. Bench workloads fill ~15 slots; real apps will sit
61/// comfortably in 256. Lookup is O(1), collisions clear the cache (see
62/// `plan_cache::PlanCache::insert`).
63const PLAN_CACHE_CAPACITY: usize = 256;
64const SQL_RAW_CACHE_SALT: u64 = 0x7261_772d_7371_6c01;
65
66#[inline]
67fn sql_raw_cache_hash(hash: u64) -> u64 {
68 hash ^ SQL_RAW_CACHE_SALT
69}
70/// Re-exported rather than re-declared: `Catalog::install_auto_wal_archive`
71/// takes this exact type, and a second hand-written copy of it here is a
72/// parity that has to be maintained by hand.
73use powdb_storage::catalog::WalArchiveHook;
74
75/// Maximum number of rows a join may produce before the executor aborts.
76/// Prevents Cartesian-product blowups (e.g. `T cross join T` on 10K rows
77/// would produce 100M rows in memory without this cap).
78pub(super) const MAX_JOIN_ROWS: usize = 1_000_000;
79
80/// Maximum candidate pairs allowed for a fallback nested-loop join. This is
81/// grounded in the release benchmark's conservative 250 ms evaluation budget.
82pub(super) const MAX_NESTED_LOOP_PAIRS: usize = 6_400_000;
83
84/// Maximum number of rows that may be materialized for sorting.
85/// Queries that exceed this should add a LIMIT clause to narrow the input
86/// before sorting.
87pub(super) const MAX_SORT_ROWS: usize = 10_000_000;
88
89#[inline]
90pub(super) fn check_join_limit(row_count: usize) -> Result<(), QueryError> {
91 if row_count > MAX_JOIN_ROWS {
92 return Err(QueryError::JoinLimitExceeded);
93 }
94 Ok(())
95}
96
97// ─── Mission D11 Phase 1: scalar hot-loop helpers ─────────────────────────
98//
99// These macros expand into the scan body of `agg_single_col_fast` and sit
100// inside the `for_each_row_raw` closure. They exist to:
101//
102// 1. Split the loop on presence of a predicate *outside* the hot body,
103// so the no-predicate path (agg_sum/agg_min/agg_max bench workloads)
104// never pays the `Option<CompiledPredicate>` branch per row.
105// 2. Drop two bounds checks per row by reading the null bitmap byte
106// and the 8-byte value via raw pointer casts.
107//
108// SAFETY (shared across every call site below):
109//
110// - `$bmp_byte` is `col_idx / 8` where `col_idx < n_cols`, and the row body
111// encoding stores `bitmap_size = n_cols.div_ceil(8)` bytes of bitmap
112// starting at body offset 2. So `bmp_off = row_body_base(row) + 2 +
113// $bmp_byte < row_len`, and `get_unchecked(bmp_off)` is inside the
114// row slice.
115// - `$off = 2 + bitmap_size + fixed_offsets[col_idx]` is body-relative for a fixed-size
116// column. Every fixed-size column contributes `fixed_size(type_id)`
117// bytes to the fixed region, so the row always has
118// `[data_off .. data_off + 8]` available for any i64/f64 column, where
119// `data_off = row_body_base(row) + $off` — enforced by the row encoder
120// (`storage/src/row.rs`) and the schema invariant that a row with a
121// given schema has enough body bytes for `2 + bitmap_size + fixed_region_size`.
122// - Both macros are only invoked from `agg_single_col_fast`, which
123// early-returns if the column isn't Int/Float (8-byte fixed) and
124// early-returns if `fast.fixed_offsets[col_idx]` is `None`.
125macro_rules! agg_int_loop {
126 (
127 $self:expr, $table:expr, $pred:expr,
128 $bmp_byte:expr, $bmp_bit:expr, $off:expr,
129 |$v:ident : i64| $body:block
130 ) => {{
131 let bmp_byte = $bmp_byte;
132 let bmp_bit = $bmp_bit;
133 let off = $off;
134 if let Some(pred) = &$pred {
135 for_each_row_raw_cancellable(&$self.catalog, $table, |_rid, data| {
136 if !pred(data) {
137 return;
138 }
139 let base = row_body_base(data);
140 let bmp_off = base + 2 + bmp_byte;
141 let data_off = base + off;
142 // Bounds guard: skip corrupt/truncated rows that are too
143 // short to contain the bitmap byte or the 8-byte value.
144 if bmp_off >= data.len() || data_off + 8 > data.len() {
145 return;
146 }
147 // SAFETY: `bmp_off < data.len()` is checked above.
148 // The bitmap byte lives at body offset 2..2+bitmap_size in
149 // the row encoding, and bmp_byte = col_idx / 8 < bitmap_size.
150 // Corrupt rows are rejected by the bounds guard.
151 let bmp = unsafe { *data.get_unchecked(bmp_off) };
152 if (bmp >> bmp_bit) & 1 == 1 {
153 return;
154 }
155 // SAFETY: `data_off + 8 <= data.len()` is checked above.
156 // `data_off = base + 2 + bitmap_size + fixed_offsets[col_idx]`
157 // points to an 8-byte i64 in the fixed-size region of the row.
158 // The pointer cast is valid because we read exactly 8
159 // bytes via from_le_bytes. Corrupt rows are rejected by
160 // the bounds guard.
161 let $v: i64 =
162 unsafe { i64::from_le_bytes(*(data.as_ptr().add(data_off) as *const [u8; 8])) };
163 $body
164 })?;
165 } else {
166 for_each_row_raw_cancellable(&$self.catalog, $table, |_rid, data| {
167 let base = row_body_base(data);
168 let bmp_off = base + 2 + bmp_byte;
169 let data_off = base + off;
170 // Bounds guard: skip corrupt/truncated rows.
171 if bmp_off >= data.len() || data_off + 8 > data.len() {
172 return;
173 }
174 // SAFETY: `bmp_off < data.len()` is checked above.
175 // See the predicate branch for the full invariant.
176 let bmp = unsafe { *data.get_unchecked(bmp_off) };
177 if (bmp >> bmp_bit) & 1 == 1 {
178 return;
179 }
180 // SAFETY: `data_off + 8 <= data.len()` is checked above.
181 // See the predicate branch for the full invariant.
182 let $v: i64 =
183 unsafe { i64::from_le_bytes(*(data.as_ptr().add(data_off) as *const [u8; 8])) };
184 $body
185 })?;
186 }
187 }};
188}
189
190macro_rules! agg_float_loop {
191 (
192 $self:expr, $table:expr, $pred:expr,
193 $bmp_byte:expr, $bmp_bit:expr, $off:expr,
194 |$v:ident : f64| $body:block
195 ) => {{
196 let bmp_byte = $bmp_byte;
197 let bmp_bit = $bmp_bit;
198 let off = $off;
199 if let Some(pred) = &$pred {
200 for_each_row_raw_cancellable(&$self.catalog, $table, |_rid, data| {
201 if !pred(data) {
202 return;
203 }
204 let base = row_body_base(data);
205 let bmp_off = base + 2 + bmp_byte;
206 let data_off = base + off;
207 // Bounds guard: skip corrupt/truncated rows that are too
208 // short to contain the bitmap byte or the 8-byte value.
209 if bmp_off >= data.len() || data_off + 8 > data.len() {
210 return;
211 }
212 // SAFETY: `bmp_off < data.len()` is checked above.
213 // The bitmap byte lives at body offset 2..2+bitmap_size in
214 // the row encoding, and bmp_byte = col_idx / 8 < bitmap_size.
215 // Corrupt rows are rejected by the bounds guard.
216 let bmp = unsafe { *data.get_unchecked(bmp_off) };
217 if (bmp >> bmp_bit) & 1 == 1 {
218 return;
219 }
220 // SAFETY: `data_off + 8 <= data.len()` is checked above.
221 // `data_off = base + 2 + bitmap_size + fixed_offsets[col_idx]`
222 // points to an 8-byte f64 in the fixed-size region of the row.
223 // The pointer cast is valid because we read exactly 8
224 // bytes via from_le_bytes. Corrupt rows are rejected by
225 // the bounds guard.
226 let $v: f64 =
227 unsafe { f64::from_le_bytes(*(data.as_ptr().add(data_off) as *const [u8; 8])) };
228 $body
229 })?;
230 } else {
231 for_each_row_raw_cancellable(&$self.catalog, $table, |_rid, data| {
232 let base = row_body_base(data);
233 let bmp_off = base + 2 + bmp_byte;
234 let data_off = base + off;
235 // Bounds guard: skip corrupt/truncated rows.
236 if bmp_off >= data.len() || data_off + 8 > data.len() {
237 return;
238 }
239 // SAFETY: `bmp_off < data.len()` is checked above.
240 // See the predicate branch for the full invariant.
241 let bmp = unsafe { *data.get_unchecked(bmp_off) };
242 if (bmp >> bmp_bit) & 1 == 1 {
243 return;
244 }
245 // SAFETY: `data_off + 8 <= data.len()` is checked above.
246 // See the predicate branch for the full invariant.
247 let $v: f64 =
248 unsafe { f64::from_le_bytes(*(data.as_ptr().add(data_off) as *const [u8; 8])) };
249 $body
250 })?;
251 }
252 }};
253}
254
255// Submodules that use the macros above — must be declared after macro_rules!.
256mod plan_exec;
257mod prepared;
258
259#[cfg(test)]
260mod tests;
261
262// Re-exports for the public API
263pub use self::prepared::PreparedQuery;
264
265use self::plan_exec::{
266 aggregate_rows, aggregate_rows_with_provenance, compare_order_values,
267 cooperative_stable_sort_by, counts_every_row, exec_group_by, exec_group_by_with_provenance,
268 execute_materialized_join, execute_window, for_each_row_raw_cancellable, format_plan_tree,
269 literal_limit, predicate_column_indices_json, range_matches, synthesize_range_predicate,
270 union_rows, validate_plan, LoweredPlan,
271};
272
273/// Mission infra-1: classify a parsed statement as read-only vs. mutating.
274/// Used by [`Engine::execute_powql_readonly`] and by the server handler
275/// to decide between the RwLock reader and writer sides. `Union` recurses
276/// because each side can independently be read/write (though in practice
277/// both sides are reads — the parser only builds Union from query shapes).
278pub fn is_read_only_statement(stmt: &Statement) -> bool {
279 match stmt {
280 Statement::Query(_) => true,
281 Statement::ListTypes | Statement::Describe(_) | Statement::ListLinks => true,
282 Statement::Union(u) => is_read_only_statement(&u.left) && is_read_only_statement(&u.right),
283 Statement::Insert(_)
284 | Statement::Upsert(_)
285 | Statement::UpdateQuery(_)
286 | Statement::DeleteQuery(_)
287 | Statement::CreateType(_)
288 | Statement::CreateLink(_)
289 | Statement::DropLink(_)
290 | Statement::AlterTable(_)
291 | Statement::DropTable(_)
292 | Statement::CreateView(_)
293 | Statement::RefreshView(_)
294 | Statement::DropView(_) => false,
295 Statement::Begin | Statement::Commit | Statement::Rollback => false,
296 Statement::Explain(inner) => is_read_only_statement(inner),
297 }
298}
299
300/// Map a read-only executor result into the read-only-engine surface: the
301/// internal "this statement writes" sentinel ([`QueryError::ReadonlyNeedsWrite`])
302/// becomes the terminal, operator-facing [`QueryError::ReadonlyMode`]. There is
303/// no writer to escalate to in read-only mode, so the sentinel never leaves the
304/// engine.
305fn to_readonly_terminal(
306 result: Result<QueryResult, QueryError>,
307) -> Result<QueryResult, QueryError> {
308 match result {
309 Err(QueryError::ReadonlyNeedsWrite) => Err(QueryError::ReadonlyMode),
310 other => other,
311 }
312}
313
314/// Return whether executing this read plan would have to refresh a dirty
315/// materialized view. This is intentionally a whole-plan preflight: the server
316/// may retry only this typed condition under exclusive admission, so it must be
317/// raised before any input branch performs row work.
318fn plan_reads_dirty_view(plan: &PlanNode, views: &ViewRegistry) -> bool {
319 match plan {
320 PlanNode::SeqScan { table }
321 | PlanNode::AliasScan { table, .. }
322 | PlanNode::IndexScan { table, .. }
323 | PlanNode::RangeScan { table, .. }
324 | PlanNode::ExprIndexScan { table, .. }
325 | PlanNode::ExprRangeScan { table, .. }
326 | PlanNode::OrderedExprIndexScan { table, .. } => views.is_dirty(table),
327
328 PlanNode::Filter { input, .. }
329 | PlanNode::Project { input, .. }
330 | PlanNode::Sort { input, .. }
331 | PlanNode::Limit { input, .. }
332 | PlanNode::Offset { input, .. }
333 | PlanNode::Aggregate { input, .. }
334 | PlanNode::Distinct { input }
335 | PlanNode::GroupBy { input, .. }
336 | PlanNode::Window { input, .. } => plan_reads_dirty_view(input, views),
337
338 PlanNode::NestedLoopJoin { left, right, .. } | PlanNode::Union { left, right, .. } => {
339 plan_reads_dirty_view(left, views) || plan_reads_dirty_view(right, views)
340 }
341
342 // Nested sub-query fields scan their child tables directly, so a
343 // dirty child view needs the same escalation as the parent input.
344 PlanNode::NestedProject { input, fields } => {
345 plan_reads_dirty_view(input, views)
346 || fields.iter().any(|field| match field {
347 crate::plan::NestedProjectField::Nested(nested) => {
348 let mut dirty = false;
349 nested.visit_tables(&mut |table| dirty |= views.is_dirty(table));
350 // A block link traversal's child table is a placeholder
351 // until execution-time catalog resolution, so
352 // visit_tables cannot see it; escalate conservatively
353 // whenever any view is dirty.
354 dirty
355 || (plan_exec::nested_fields_have_via_link(std::slice::from_ref(field))
356 && any_view_dirty(views))
357 }
358 crate::plan::NestedProjectField::Plain(_) => false,
359 // Scalar link hop tables are likewise unknown here.
360 crate::plan::NestedProjectField::Link(link) => {
361 link.resolved.is_none() && any_view_dirty(views)
362 }
363 })
364 }
365
366 // EXPLAIN formats its input without executing it, so inspecting a plan
367 // that names a dirty view never requires a refresh.
368 PlanNode::Explain { .. }
369 | PlanNode::AlterTable { .. }
370 | PlanNode::DropTable { .. }
371 | PlanNode::Insert { .. }
372 | PlanNode::Upsert { .. }
373 | PlanNode::Update { .. }
374 | PlanNode::Delete { .. }
375 | PlanNode::CreateTable { .. }
376 | PlanNode::CreateLink { .. }
377 | PlanNode::DropLink { .. }
378 | PlanNode::ListTypes
379 | PlanNode::Describe { .. }
380 | PlanNode::ListLinks
381 | PlanNode::CreateView { .. }
382 | PlanNode::RefreshView { .. }
383 | PlanNode::DropView { .. }
384 | PlanNode::Begin
385 | PlanNode::Commit
386 | PlanNode::Rollback => false,
387 }
388}
389
390/// Open the materialized-view registry over `data_dir`, marking every view
391/// whose stored source no longer parses as dirty.
392///
393/// Dirty is how a read is made to go through `Engine::refresh_view`, which is
394/// where an unreadable source is reported (see
395/// `plan_exec::dispatch::parse_stored_view_source`). Without this, such a view
396/// is CLEAN forever: its dependency list was empty for the same reason its
397/// source will not parse, so no mutation ever dirties it, no read ever
398/// refreshes it, and every read serves stale rows with no error at all. Marking
399/// it dirty converts that into the typed error, and touches only memory, so a
400/// database that is merely being inspected is not modified.
401/// Whether `data_dir` already holds a catalog, i.e. whether reopening it is
402/// meant to find an existing database.
403///
404/// The file name is the on-disk identity of a PowDB directory, shared by the
405/// backup, sync and server crates; it is a format constant, not a storage
406/// implementation detail, and changing it would be a format break carrying its
407/// own migration.
408fn catalog_file_present(data_dir: &Path) -> bool {
409 data_dir.join("catalog.bin").exists()
410}
411
412/// Open the view registry, refusing the whole database when the file is there
413/// but unreadable.
414///
415/// [`ViewRegistry::open`] already answers "no views" for an absent file, so an
416/// error here means `views.bin` exists and did not decode. Treating that as an
417/// empty registry lost every `materialize` statement ever run: reads of a view
418/// name reported an unknown table, the backing tables stayed on disk with
419/// nothing to refresh them, and the next `materialize` overwrote the file that
420/// still held the definitions. A damaged heap and a missing catalog both refuse
421/// the open (see `new_inner`), and so does this.
422fn open_view_registry(data_dir: &Path) -> io::Result<ViewRegistry> {
423 let mut registry = ViewRegistry::open(data_dir).map_err(|e| {
424 io::Error::new(
425 e.kind(),
426 format!(
427 "{} could not be read ({e}); it defines this database's materialized views, so it must be restored or removed before the database can be opened",
428 data_dir
429 .join(powdb_storage::data_dir::VIEW_REGISTRY_FILE)
430 .display()
431 ),
432 )
433 })?;
434 let unreadable: Vec<String> = registry
435 .list_views()
436 .iter()
437 .filter(|name| {
438 registry
439 .get(name)
440 .is_some_and(|def| crate::parser::parse(&def.query).is_err())
441 })
442 .map(|name| (*name).to_string())
443 .collect();
444 for name in unreadable {
445 warn!(
446 view = %name,
447 "materialized view has an unparseable stored source; reads will report an error \
448 rather than serve rows that cannot be recomputed"
449 );
450 registry.mark_dirty_in_memory(&name);
451 }
452 Ok(registry)
453}
454
455/// Names of the dirty materialized views this plan scans, taken from table
456/// names the plan itself carries.
457///
458/// Deliberately narrower than [`plan_reads_dirty_view`]: that one escalates
459/// conservatively for link traversals whose child table is only knowable after
460/// execution-time catalog resolution, which is the right answer for a preflight
461/// that can only say yes or no, but it cannot name a table to refresh. The
462/// write path refreshes those where it resolves them.
463fn collect_dirty_scanned_views(plan: &PlanNode, views: &ViewRegistry, out: &mut Vec<String>) {
464 match plan {
465 PlanNode::SeqScan { table }
466 | PlanNode::AliasScan { table, .. }
467 | PlanNode::IndexScan { table, .. }
468 | PlanNode::RangeScan { table, .. }
469 | PlanNode::ExprIndexScan { table, .. }
470 | PlanNode::ExprRangeScan { table, .. }
471 | PlanNode::OrderedExprIndexScan { table, .. }
472 if views.is_dirty(table) && !out.iter().any(|name| name == table) =>
473 {
474 out.push(table.clone());
475 }
476
477 PlanNode::Filter { input, .. }
478 | PlanNode::Project { input, .. }
479 | PlanNode::Sort { input, .. }
480 | PlanNode::Limit { input, .. }
481 | PlanNode::Offset { input, .. }
482 | PlanNode::Aggregate { input, .. }
483 | PlanNode::Distinct { input }
484 | PlanNode::GroupBy { input, .. }
485 | PlanNode::Window { input, .. }
486 | PlanNode::NestedProject { input, .. } => collect_dirty_scanned_views(input, views, out),
487
488 PlanNode::NestedLoopJoin { left, right, .. } | PlanNode::Union { left, right, .. } => {
489 collect_dirty_scanned_views(left, views, out);
490 collect_dirty_scanned_views(right, views, out);
491 }
492
493 // EXPLAIN formats its input without executing it, and the write
494 // statements own their own view bookkeeping.
495 _ => {}
496 }
497}
498
499fn nested_project_output_columns(fields: &[crate::plan::NestedProjectField]) -> Vec<String> {
500 fields
501 .iter()
502 .map(|field| match field {
503 crate::plan::NestedProjectField::Plain(field) => field
504 .alias
505 .clone()
506 .unwrap_or_else(|| projection_output_name(&field.expr)),
507 crate::plan::NestedProjectField::Nested(nested) => nested.name.clone(),
508 crate::plan::NestedProjectField::Link(link) => link.name.clone(),
509 })
510 .collect()
511}
512
513/// True when any registered materialized view is currently dirty. Conservative
514/// escalation test for plans whose scanned tables are not knowable before
515/// execution-time catalog resolution (link traversals resolve their child
516/// tables from the persistent catalog at query time).
517fn any_view_dirty(views: &ViewRegistry) -> bool {
518 views.list_views().iter().any(|v| views.is_dirty(v))
519}
520
521pub struct Engine {
522 catalog: Catalog,
523 /// Exclusive PID-based lock on the data directory, held for the engine's
524 /// lifetime so two separate processes can't open the same dir and corrupt
525 /// the heap/WAL. Released on clean drop; a `mem::forget` crash leaves a
526 /// stale lock the next open takes over. Leading `_`: it does its work
527 /// through `Drop`, never read directly.
528 _dir_lock: powdb_storage::dir_lock::DirLock,
529 /// Mission D9 — cached parsed+planned query trees keyed by canonical
530 /// hash. Saves the ~3μs parse+plan cost on repeat queries that differ
531 /// only in literal values.
532 ///
533 /// Mission infra-1: wrapped in `Mutex` so the read path can be driven
534 /// by `&self`. The critical section is extremely short — a single
535 /// hashmap lookup + plan clone on a hit, or a single insert on a miss.
536 /// A full `RwLock` would be over-engineered here; the contention window
537 /// is smaller than the read-path scan work it gates.
538 plan_cache: Mutex<PlanCache>,
539 /// Mission C Phase 13: reusable `Vec<Value>` scratch buffer for the
540 /// prepared-insert fast path. `execute_prepared` used to allocate a
541 /// fresh `vec![Value::Empty; n_cols]` on every insert; recycling this
542 /// buffer shaves one heap alloc per row on `insert_batch_1k`.
543 insert_values_scratch: Vec<Value>,
544 /// Materialized view registry: tracks view definitions, dependencies,
545 /// and dirty state. Views are backed by regular catalog tables; this
546 /// registry adds the lifecycle metadata.
547 view_registry: ViewRegistry,
548 in_transaction: bool,
549 transaction_aborted: AtomicBool,
550 implicit_transaction: bool,
551 statement_depth: usize,
552 poisoned: bool,
553 #[cfg(feature = "testing")]
554 statement_commit_hook: Option<fn(StatementCommitPhase)>,
555 /// WS2 — per-query memory budget ceiling (bytes). The running total lives
556 /// in a thread-local (see [`mem_budget`]) and is reset at every top-level
557 /// query entry, so sort/join/GROUP BY/IN-list materialization can be capped
558 /// without OOM-killing the process. This field holds only the *limit* (a
559 /// plain `usize`, so `Engine` stays `Sync` for the concurrent read path).
560 /// Default [`mem_budget::DEFAULT_QUERY_MEMORY_LIMIT`] (256 MB); overridable
561 /// via `Engine::with_memory_limit` (server reads `POWDB_QUERY_MEMORY_LIMIT`).
562 query_memory_limit: usize,
563 /// Maximum candidate pairs a fallback nested-loop join may evaluate before
564 /// it is rejected. Default [`MAX_NESTED_LOOP_PAIRS`], overridable via
565 /// [`Engine::set_nested_loop_pair_limit`] (server reads
566 /// `POWDB_MAX_NESTED_LOOP_PAIRS`). A plain `usize` so `Engine` stays `Sync`.
567 nested_loop_pair_limit: usize,
568 wal_archive_hook: Option<WalArchiveHook>,
569 /// True when opened via [`Engine::open_read_only`] for snapshot serving. In
570 /// this mode the catalog and its files are read-only, the `DirLock` is a
571 /// shared reader lock, and every mutating execute path returns the terminal
572 /// [`QueryError::ReadonlyMode`] instead of ever touching disk.
573 read_only: bool,
574 /// Test-only: when true, every executor fast path and the compiled
575 /// predicate entry decline to fire, so the query runs through the generic
576 /// evaluator. See [`Engine::set_force_generic_path`]. The field only
577 /// exists under the `testing` feature, and [`Engine::generic_path_forced`]
578 /// is a compile-time `false` without it, so no shipped build pays a branch.
579 #[cfg(feature = "testing")]
580 force_generic_path: bool,
581 /// Test-only: the name of every fast-path check site that has declined
582 /// because `force_generic_path` was set. A bare count would only tell a
583 /// test that *something* declined; a query usually passes several check
584 /// sites, so a count stays non-zero even when the one site under test lost
585 /// its guard. Recording names is what makes "this shape really was
586 /// diverted" provable, and a missed site is a hole in the whole runner.
587 /// Behind a `Mutex` because the read path drives the engine by `&self` and
588 /// `Engine` must stay `Sync`; it is only ever locked while the switch is on.
589 #[cfg(feature = "testing")]
590 forced_generic_sites: std::sync::Mutex<Vec<&'static str>>,
591}
592
593impl Engine {
594 /// Open or create a PowDB engine rooted at `data_dir`.
595 ///
596 /// If the directory already contains a catalog, it is reopened.
597 /// Otherwise a fresh empty database is created.
598 ///
599 /// # Examples
600 ///
601 /// ```
602 /// use powdb_query::executor::Engine;
603 ///
604 /// let dir = tempfile::tempdir().unwrap();
605 /// let engine = Engine::new(dir.path()).unwrap();
606 /// // Engine is ready — the directory now contains a catalog.
607 /// ```
608 pub fn new(data_dir: &Path) -> io::Result<Self> {
609 Self::new_inner(data_dir, None)
610 }
611
612 /// Open or create an engine that archives WAL records before any recovery,
613 /// rollback, or drop checkpoint truncates them. This keeps the query crate
614 /// independent of replication metadata while giving sync-aware callers one
615 /// lifecycle boundary for retained-history preservation.
616 pub fn new_with_wal_archive<F>(data_dir: &Path, archive: F) -> io::Result<Self>
617 where
618 F: Fn(&Path, &[powdb_storage::wal::WalRecord]) -> io::Result<()> + Send + Sync + 'static,
619 {
620 Self::new_inner(data_dir, Some(Arc::new(archive)))
621 }
622
623 fn new_inner(data_dir: &Path, wal_archive_hook: Option<WalArchiveHook>) -> io::Result<Self> {
624 powdb_storage::create_data_dir_secure(data_dir)?;
625 // Refuse to open a directory another live process already holds, before
626 // touching any on-disk state (concurrent writers corrupt the heap/WAL).
627 let dir_lock = powdb_storage::dir_lock::DirLock::acquire(data_dir)?;
628 // Try to reopen an existing database first; only create a fresh
629 // catalog when there isn't one already on disk.
630 let catalog_result = match &wal_archive_hook {
631 Some(hook) => {
632 let hook = Arc::clone(hook);
633 Catalog::open_with_wal_archive(data_dir, move |dir, records| hook(dir, records))
634 }
635 None => Catalog::open(data_dir),
636 };
637 let mut catalog = match catalog_result {
638 Ok(c) => {
639 info!(data_dir = %data_dir.display(), "engine reopened existing database");
640 c
641 }
642 // Exactly one NotFound means "there is no database here": the one
643 // `Catalog::open` raises because the catalog file itself is absent.
644 // Every other file the open path touches raises the same kind, a
645 // table's `.heap` most obviously, and `Catalog::create` renames a
646 // fresh empty catalog over the existing one and truncates the WAL.
647 // Treating any NotFound as a fresh directory therefore turned a
648 // single missing heap into silent, total loss of every table,
649 // including the ones still intact on disk. Damage must stay loud so
650 // the missing file can be restored.
651 Err(e) if e.kind() == io::ErrorKind::NotFound && !catalog_file_present(data_dir) => {
652 info!(data_dir = %data_dir.display(), "engine initialized fresh database");
653 Catalog::create(data_dir)?
654 }
655 Err(e) => return Err(e),
656 };
657 // `Catalog::open_with_wal_archive` borrows its hook for the open and
658 // cannot keep it, so a catalog that had one used to stop checkpointing
659 // altogether rather than truncate records it could not publish. Hand it
660 // one it can keep: the automatic checkpoint archives, then truncates.
661 if let Some(hook) = &wal_archive_hook {
662 catalog.install_auto_wal_archive(Arc::clone(hook));
663 }
664 let view_registry = open_view_registry(data_dir)?;
665 view_registry.validate_backing_tables(&catalog)?;
666 Ok(Engine {
667 catalog,
668 _dir_lock: dir_lock,
669 plan_cache: Mutex::new(PlanCache::new(PLAN_CACHE_CAPACITY)),
670 insert_values_scratch: Vec::new(),
671 view_registry,
672 in_transaction: false,
673 transaction_aborted: AtomicBool::new(false),
674 implicit_transaction: false,
675 statement_depth: 0,
676 poisoned: false,
677 #[cfg(feature = "testing")]
678 statement_commit_hook: None,
679 query_memory_limit: mem_budget::DEFAULT_QUERY_MEMORY_LIMIT,
680 nested_loop_pair_limit: MAX_NESTED_LOOP_PAIRS,
681 wal_archive_hook,
682 read_only: false,
683 #[cfg(feature = "testing")]
684 force_generic_path: false,
685 #[cfg(feature = "testing")]
686 forced_generic_sites: std::sync::Mutex::new(Vec::new()),
687 })
688 }
689
690 /// Open an engine **read-only** over a quiescent data directory for snapshot
691 /// serving (tier 1 of the replica story). This is the supported way to serve
692 /// a restored backup or a checkpointed replica with no write gate at all:
693 ///
694 /// - the catalog and every heap/index/WAL file are opened read-only
695 /// ([`Catalog::open_read_only`]), so nothing on disk is ever mutated;
696 /// - a **shared reader** [`DirLock`](powdb_storage::dir_lock::DirLock) is
697 /// taken, so N read-only processes may serve the same directory
698 /// concurrently, while a read-write open refuses to start against live
699 /// readers (and readers refuse a live writer);
700 /// - a non-empty WAL is refused with an actionable error rather than replayed
701 /// (the directory must be recovered by a read-write engine first);
702 /// - every mutating statement returns the terminal [`QueryError::ReadonlyMode`].
703 ///
704 /// Materialized-view refresh must happen before snapshotting: a query over a
705 /// stale (dirty) view is refused in this mode rather than silently escalating.
706 pub fn open_read_only(data_dir: &Path) -> io::Result<Self> {
707 // Validate readability without ever chmod-ing the directory (a read-only
708 // open must leave it byte-identical).
709 powdb_storage::validate_data_dir_read_only(data_dir)?;
710 // A shared reader lock: coexists with other readers, refuses a live writer.
711 let dir_lock = powdb_storage::dir_lock::DirLock::acquire_reader(data_dir)?;
712 let catalog = Catalog::open_read_only(data_dir)?;
713 info!(data_dir = %data_dir.display(), "engine opened read-only for snapshot serving");
714 let view_registry = open_view_registry(data_dir)?;
715 view_registry.validate_backing_tables(&catalog)?;
716 Ok(Engine {
717 catalog,
718 _dir_lock: dir_lock,
719 plan_cache: Mutex::new(PlanCache::new(PLAN_CACHE_CAPACITY)),
720 insert_values_scratch: Vec::new(),
721 view_registry,
722 in_transaction: false,
723 transaction_aborted: AtomicBool::new(false),
724 implicit_transaction: false,
725 statement_depth: 0,
726 poisoned: false,
727 #[cfg(feature = "testing")]
728 statement_commit_hook: None,
729 query_memory_limit: mem_budget::DEFAULT_QUERY_MEMORY_LIMIT,
730 nested_loop_pair_limit: MAX_NESTED_LOOP_PAIRS,
731 // No WAL-archive hook: a read-only engine never writes, so its Drop
732 // must never checkpoint (the hook is what would drive that).
733 wal_archive_hook: None,
734 read_only: true,
735 #[cfg(feature = "testing")]
736 force_generic_path: false,
737 #[cfg(feature = "testing")]
738 forced_generic_sites: std::sync::Mutex::new(Vec::new()),
739 })
740 }
741
742 /// Read-only open with an explicit per-query memory budget (bytes).
743 pub fn open_read_only_with_memory_limit(
744 data_dir: &Path,
745 limit_bytes: usize,
746 ) -> io::Result<Self> {
747 let mut engine = Engine::open_read_only(data_dir)?;
748 engine.set_query_memory_limit(limit_bytes);
749 Ok(engine)
750 }
751
752 /// Whether this engine was opened read-only for snapshot serving.
753 pub fn is_read_only(&self) -> bool {
754 self.read_only
755 }
756
757 /// Plan `stmt` and lower it. **The only place in the executor that calls
758 /// the planner.**
759 ///
760 /// Planner output is not executable as it stands: the planner is pure, so
761 /// it emits `IndexScan` / `RangeScan` probes speculatively and leaves every
762 /// literal exactly as written, and lowering is what decides whether those
763 /// probes exist and what key bytes they address. Running raw planner output
764 /// is therefore not a missed optimization, it is a different answer.
765 ///
766 /// Eight subquery materialization sites used to plan a statement and hand
767 /// the result straight to `execute_plan` / `execute_plan_readonly`. The
768 /// consequence was that a predicate answered correctly at the top level and
769 /// incorrectly one level of nesting down:
770 /// `count(H filter .price < 3)` gave 2 while
771 /// `count(H filter .n in (H filter .price < 3 { .n }))` gave 0 over the
772 /// same rows and the same index.
773 ///
774 /// Funnelling every plan through here is what makes that unrepeatable: a
775 /// ninth site has to call this to get a plan at all, and what it gets back
776 /// is a [`LoweredPlan`], which is the only thing the execution entry points
777 /// accept. `no_execution_entry_point_can_receive_an_unlowered_plan` in
778 /// `tests/cross_type_index_parity.rs` fails the build if a second call to
779 /// the planner appears anywhere under `src/executor/`.
780 fn plan_and_lower(&self, stmt: Statement) -> Result<LoweredPlan, QueryError> {
781 Ok(self.plan_and_lower_cacheable(stmt)?.1)
782 }
783
784 /// [`Engine::plan_and_lower`] for the callers that also need the raw plan,
785 /// which is only ever the plan cache: the cache stores the pre-lowering
786 /// tree, because lowering is a function of catalog state and the cache
787 /// outlives DDL. Lowering therefore runs on every cache hit as well.
788 fn plan_and_lower_cacheable(
789 &self,
790 stmt: Statement,
791 ) -> Result<(PlanNode, LoweredPlan), QueryError> {
792 let plan =
793 crate::planner::plan_statement(stmt).map_err(|e| QueryError::Parse(e.to_string()))?;
794 let lowered = self.lower(&plan)?;
795 Ok((plan, lowered))
796 }
797
798 /// [`Engine::plan_and_lower_cacheable`] from PowQL text. The only call to
799 /// `planner::plan` under `src/executor/`, for the same reason.
800 fn plan_text_and_lower(&self, input: &str) -> Result<(PlanNode, LoweredPlan), QueryError> {
801 let plan = planner::plan(input).map_err(|e| QueryError::Parse(e.to_string()))?;
802 let lowered = self.lower(&plan)?;
803 Ok((plan, lowered))
804 }
805
806 /// Lower a plan that came from somewhere other than the planner: the plan
807 /// cache, or a fallback the executor built from an already-lowered tree.
808 /// Lowering is idempotent, so calling it on a plan that has been through it
809 /// already is a no-op.
810 fn lower(&self, plan: &PlanNode) -> Result<LoweredPlan, QueryError> {
811 LoweredPlan::of(&self.catalog, plan)
812 }
813
814 /// Test-only: the EXPLAIN text of `query`'s plan after `passes` rounds of
815 /// lowering.
816 ///
817 /// Routing every plan through [`Engine::plan_and_lower`] is only sound if
818 /// lowering is idempotent: a plan that reaches an entry point already
819 /// lowered (a re-lowered cache hit, a prepared template, a fallback the
820 /// executor rebuilt from a lowered tree) must come out unchanged rather
821 /// than rewritten a second time. This exposes the pass so a test can hold
822 /// that directly instead of inferring it from answers.
823 ///
824 /// Only available with the `testing` feature.
825 #[cfg(feature = "testing")]
826 pub fn lowered_plan_text(&self, query: &str, passes: usize) -> Result<String, QueryError> {
827 let (_, mut plan) = self.plan_text_and_lower(query)?;
828 for _ in 1..passes.max(1) {
829 plan = self.lower(plan.node())?;
830 }
831 Ok(format_plan_tree(&self.catalog, plan.node(), 0))
832 }
833
834 /// Run a lowered plan on the mutable path. `Engine::execute_plan` stays
835 /// public and takes a bare `&PlanNode` for embedders that build plans
836 /// themselves, and lowers it before calling this; every path inside the
837 /// executor goes through here so that what it runs is a plan the type
838 /// system says was lowered.
839 fn execute_lowered(&mut self, plan: &LoweredPlan) -> Result<QueryResult, QueryError> {
840 self.ensure_plan_allowed(plan.node())?;
841 self.refresh_dirty_views_read_by(plan.node())?;
842 let result = self.dispatch_mut(plan.node());
843 Self::raise_arith_fault(result)
844 }
845
846 /// Turn a fault the expression evaluator recorded during `result` into the
847 /// error it had no channel to report itself.
848 ///
849 /// An overflowing `+` or a per-row zero divisor evaluates to `Value::Empty`,
850 /// which is indistinguishable from a missing column: the row came back with
851 /// a NULL in a projection, matched nothing in a predicate, and an `update`
852 /// wrote that NULL into a required column. An existing error wins, because
853 /// it is the more specific one; either way the slot is cleared.
854 fn raise_arith_fault(
855 result: Result<QueryResult, QueryError>,
856 ) -> Result<QueryResult, QueryError> {
857 match (result, take_arith_fault()) {
858 (Ok(_), Some(message)) => Err(QueryError::Execution(message)),
859 (result, _) => result,
860 }
861 }
862
863 /// Refresh every stale materialized view this plan is about to read, before
864 /// any of it runs.
865 ///
866 /// The dispatch arms each carry their own dirty check, and the fast paths
867 /// that short-circuit them do not: `V filter .id > 0 { .id }` is served by
868 /// `project_filter_limit_fast`, which takes the table name and scans the
869 /// backing heap directly, so a stale view answered with its pre-mutation
870 /// rows while the bare `V` next to it answered correctly. Checking at every
871 /// fast path is how that happened; checking once, here, at the boundary
872 /// every statement and subquery crosses, is what makes it not recur.
873 ///
874 /// The walk only runs when a view is dirty at all, and only refreshes views
875 /// the plan actually names, so refreshing one cannot re-enter itself
876 /// through its own source query.
877 fn refresh_dirty_views_read_by(&mut self, plan: &PlanNode) -> Result<(), QueryError> {
878 if !any_view_dirty(&self.view_registry) {
879 return Ok(());
880 }
881 let mut stale: Vec<String> = Vec::new();
882 collect_dirty_scanned_views(plan, &self.view_registry, &mut stale);
883 for name in stale {
884 self.refresh_view(&name)?;
885 }
886 Ok(())
887 }
888
889 /// Test-only: force every query onto the generic evaluator.
890 ///
891 /// The planner is pure, so one query text runs through different physical
892 /// code depending on catalog state and plan shape: a compiled byte-level
893 /// predicate, a fused scan, a bounded top-N heap, an index probe, or the
894 /// generic decode-and-evaluate loop. Those paths are supposed to be
895 /// indistinguishable from outside; twice now they were not, and the
896 /// disagreement shipped because no test ran the same query both ways.
897 ///
898 /// With this set, predicate compilation declines and every fast-path match
899 /// site falls through, so a test can execute a query optimized and
900 /// unoptimized and diff the two results.
901 /// It changes which code runs, never what the answer should be: any
902 /// difference it exposes is a bug in one of the two paths.
903 ///
904 /// Each decline records the name of the site that declined; read them back
905 /// with [`Engine::forced_generic_sites`]. A comparison whose shape never
906 /// declined anywhere is comparing one code path with itself, so the names
907 /// are what keep the comparison honest.
908 ///
909 /// Only available with the `testing` feature; there is no way to reach it
910 /// from a shipped build.
911 #[cfg(feature = "testing")]
912 pub fn set_force_generic_path(&mut self, force: bool) {
913 self.force_generic_path = force;
914 }
915
916 /// Whether fast paths are currently suppressed, recording `site` when they
917 /// are. `site` names the specific fast path being declined, so a test can
918 /// assert that the shape it ran was diverted *there* rather than merely
919 /// somewhere. Compiles to a constant `false` without the `testing` feature,
920 /// so every guarded branch folds away in a shipped build.
921 #[cfg(feature = "testing")]
922 #[inline]
923 pub(in crate::executor) fn generic_path_forced(&self, site: &'static str) -> bool {
924 if self.force_generic_path {
925 if let Ok(mut sites) = self.forced_generic_sites.lock() {
926 sites.push(site);
927 }
928 return true;
929 }
930 false
931 }
932
933 /// Test-only: the fast-path check sites that have declined so far because
934 /// [`Engine::set_force_generic_path`] is on, in the order they declined. A
935 /// shape that is supposed to reach a named fast path but never appears here
936 /// has a missing check site, not a passing test.
937 #[cfg(feature = "testing")]
938 pub fn forced_generic_sites(&self) -> Vec<&'static str> {
939 match self.forced_generic_sites.lock() {
940 Ok(sites) => sites.clone(),
941 Err(poisoned) => poisoned.into_inner().clone(),
942 }
943 }
944
945 /// Test-only: forget the recorded declines so one query's declines can be
946 /// observed in isolation.
947 #[cfg(feature = "testing")]
948 pub fn reset_forced_generic_sites(&self) {
949 match self.forced_generic_sites.lock() {
950 Ok(mut sites) => sites.clear(),
951 Err(poisoned) => poisoned.into_inner().clear(),
952 }
953 }
954
955 #[cfg(not(feature = "testing"))]
956 #[inline]
957 pub(in crate::executor) fn generic_path_forced(&self, _site: &'static str) -> bool {
958 false
959 }
960
961 /// Compile `predicate` into a byte-level closure, unless fast paths are
962 /// suppressed. This is the single entry point every executor site uses, so
963 /// [`Engine::set_force_generic_path`] cannot be bypassed by a caller that
964 /// forgets the check.
965 #[inline]
966 pub(in crate::executor) fn compile_predicate_unless_forced(
967 &self,
968 site: &'static str,
969 predicate: &Expr,
970 columns: &[String],
971 layout: &FastLayout,
972 schema: &Schema,
973 ) -> Option<CompiledPredicate> {
974 if self.generic_path_forced(site) {
975 return None;
976 }
977 compile_predicate(predicate, columns, layout, schema)
978 }
979
980 /// Open or create an engine with an explicit per-query memory limit
981 /// (bytes). Used by the server to apply `POWDB_QUERY_MEMORY_LIMIT`, and by
982 /// tests that need a tiny limit to exercise the budget guard.
983 pub fn with_memory_limit(data_dir: &Path, limit_bytes: usize) -> io::Result<Self> {
984 let mut engine = Engine::new(data_dir)?;
985 engine.set_query_memory_limit(limit_bytes);
986 Ok(engine)
987 }
988
989 /// Open or create an archive-aware engine with an explicit per-query memory
990 /// limit.
991 pub fn with_memory_limit_and_wal_archive<F>(
992 data_dir: &Path,
993 limit_bytes: usize,
994 archive: F,
995 ) -> io::Result<Self>
996 where
997 F: Fn(&Path, &[powdb_storage::wal::WalRecord]) -> io::Result<()> + Send + Sync + 'static,
998 {
999 let mut engine = Engine::new_with_wal_archive(data_dir, archive)?;
1000 engine.set_query_memory_limit(limit_bytes);
1001 Ok(engine)
1002 }
1003
1004 /// Current per-query memory limit in bytes.
1005 pub fn query_memory_limit(&self) -> usize {
1006 self.query_memory_limit
1007 }
1008
1009 /// Override the per-query memory limit in bytes (builder-style).
1010 pub fn set_query_memory_limit(&mut self, limit_bytes: usize) {
1011 self.query_memory_limit = limit_bytes;
1012 }
1013
1014 /// Current fallback nested-loop join candidate-pair cap.
1015 pub fn nested_loop_pair_limit(&self) -> usize {
1016 self.nested_loop_pair_limit
1017 }
1018
1019 /// Override the fallback nested-loop join candidate-pair cap. Used by the
1020 /// server to apply `POWDB_MAX_NESTED_LOOP_PAIRS`, and by tests that need a
1021 /// tiny cap to exercise the guard on a small join. A zero limit is clamped
1022 /// to 1 so a valid single-pair join is never rejected outright.
1023 pub fn set_nested_loop_pair_limit(&mut self, limit: usize) {
1024 self.nested_loop_pair_limit = limit.max(1);
1025 }
1026
1027 /// Set the WAL durability mode (see [`WalSyncMode`]). `Full` (the default)
1028 /// fsyncs every commit; `Normal` moves the fsync to a background flusher
1029 /// with a bounded crash-loss window; `Off` is bench-only (no durability).
1030 /// Wired from the server's `POWDB_SYNC_MODE` / `--sync-mode` config.
1031 /// Changes requested during a transaction take effect only after that
1032 /// transaction commits or rolls back, so recovery always uses its original
1033 /// mode and earlier durability obligations are not dropped.
1034 pub fn set_wal_sync_mode(&mut self, mode: WalSyncMode) {
1035 self.catalog.set_wal_sync_mode(mode);
1036 }
1037
1038 /// Run `f` with commit durability deferred — the WAL group-commit entry
1039 /// point for callers that serialize writers behind an exclusive lock.
1040 ///
1041 /// Inside `f`, Full-mode commit points register the WAL generation they
1042 /// need durable instead of fsyncing inline. The returned ticket (if any)
1043 /// must be waited on before the statement's result is acknowledged; the
1044 /// caller should release its exclusive engine lock first, so other
1045 /// committers can append while the fsync runs. That overlap is what lets
1046 /// one fsync cover many commits. A lone committer's wait performs the
1047 /// fsync immediately — group commit never introduces a delay.
1048 ///
1049 /// `Normal`/`Off` sync modes return no ticket; their durability
1050 /// contracts are unchanged. If `f` panics the engine must not be reused
1051 /// (the deferral flag may still be set); lock poisoning enforces this
1052 /// for callers that share the engine behind a lock.
1053 pub fn run_with_deferred_durability<T>(
1054 &mut self,
1055 f: impl FnOnce(&mut Engine) -> T,
1056 ) -> (T, Option<WalDurabilityTicket>) {
1057 struct DeferredGuard<'a>(&'a mut Engine);
1058 impl Drop for DeferredGuard<'_> {
1059 fn drop(&mut self) {
1060 self.0.catalog.set_wal_sync_deferred(false);
1061 if std::thread::panicking() {
1062 self.0.poison_live_state();
1063 }
1064 }
1065 }
1066 self.catalog.set_wal_sync_deferred(true);
1067 let guard = DeferredGuard(self);
1068 let out = f(guard.0);
1069 let ticket = guard.0.catalog.take_wal_durability_ticket();
1070 drop(guard);
1071 (out, ticket)
1072 }
1073
1074 /// Number of fsyncs issued against the WAL (test/metrics hook).
1075 pub fn wal_fsync_count(&self) -> u64 {
1076 self.catalog.wal_fsync_count()
1077 }
1078
1079 /// Testing-only: make the next WAL fsync fail for this engine's catalog.
1080 ///
1081 /// Available only with `powdb-query/testing`, which propagates to
1082 /// `powdb-storage/testing`. Shipping builds have no fault-injection API.
1083 #[cfg(feature = "testing")]
1084 pub fn inject_next_fsync_failure_for_testing(&self) {
1085 self.catalog.inject_next_fsync_failure_for_testing();
1086 }
1087
1088 /// Roll back the active explicit transaction while archiving any committed
1089 /// pre-transaction WAL records that recovery must replay and truncate.
1090 /// This is the sync-aware counterpart to the ordinary `rollback` statement;
1091 /// callers provide the archive hook so the query crate stays independent of
1092 /// replication metadata.
1093 pub fn rollback_transaction_with_wal_archive<F>(
1094 &mut self,
1095 archive: F,
1096 ) -> Result<QueryResult, QueryError>
1097 where
1098 F: FnMut(&Path, &[powdb_storage::wal::WalRecord]) -> io::Result<()>,
1099 {
1100 self.ensure_usable()?;
1101 if !self.in_transaction {
1102 return Err(QueryError::Execution(
1103 "no active transaction to roll back".into(),
1104 ));
1105 }
1106 if let Err(error) = self.catalog.rollback_to_last_sync_with_wal_archive(archive) {
1107 error!(%error, "transaction rollback failed");
1108 if self.catalog.is_sync_poisoned() {
1109 self.poison_live_state();
1110 return Err(QueryError::EnginePoisoned);
1111 }
1112 self.mark_transaction_aborted();
1113 return Err(QueryError::from_storage_io(error));
1114 }
1115 self.finish_rollback_after_catalog_restore()
1116 }
1117
1118 pub fn rollback_transaction_preserving_wal_archive(
1119 &mut self,
1120 ) -> Result<QueryResult, QueryError> {
1121 self.ensure_usable()?;
1122 let Some(hook) = self.wal_archive_hook.clone() else {
1123 if !self.in_transaction {
1124 return Err(QueryError::Execution(
1125 "no active transaction to roll back".into(),
1126 ));
1127 }
1128 if let Err(error) = self.catalog.rollback_to_last_sync() {
1129 error!(%error, "transaction rollback failed");
1130 if self.catalog.is_sync_poisoned() {
1131 self.poison_live_state();
1132 return Err(QueryError::EnginePoisoned);
1133 }
1134 self.mark_transaction_aborted();
1135 return Err(QueryError::from_storage_io(error));
1136 }
1137 return self.finish_rollback_after_catalog_restore();
1138 };
1139 self.rollback_transaction_with_wal_archive(move |dir, records| hook(dir, records))
1140 }
1141
1142 fn finish_rollback_after_catalog_restore(&mut self) -> Result<QueryResult, QueryError> {
1143 let views = match open_view_registry(self.catalog.data_dir()).and_then(|views| {
1144 views.validate_backing_tables(&self.catalog)?;
1145 Ok(views)
1146 }) {
1147 Ok(views) => views,
1148 Err(error) => {
1149 error!(%error, "view state could not be restored after rollback");
1150 self.poison_live_state();
1151 return Err(QueryError::EnginePoisoned);
1152 }
1153 };
1154 self.in_transaction = false;
1155 self.transaction_aborted.store(false, Ordering::Relaxed);
1156 if let Ok(mut cache) = self.plan_cache.lock() {
1157 cache.clear();
1158 }
1159 self.view_registry = views;
1160 Ok(QueryResult::Executed {
1161 message: "transaction rolled back".to_string(),
1162 })
1163 }
1164
1165 /// Enter a budgeted-statement frame for the current query. The returned
1166 /// guard must be held for the duration of the statement; on its drop the
1167 /// reentrancy depth is decremented. Only the *outermost* statement entry
1168 /// zeroes this thread's running total, so a nested `execute_powql` (the
1169 /// source query of a `create_view`/`refresh_view`) does NOT discard the
1170 /// outer frame's accounting. The accumulator is thread-local, so this never
1171 /// touches another concurrent query's total.
1172 #[must_use = "the budget guard must outlive the statement body"]
1173 pub(super) fn enter_memory_budget(&self) -> mem_budget::EnterGuard {
1174 mem_budget::enter()
1175 }
1176
1177 /// Charge the estimated footprint of a freshly materialized batch of rows
1178 /// against the current per-query budget. Returns
1179 /// [`QueryError::MemoryLimitExceeded`] cleanly if the batch would push the
1180 /// query over its limit. Used at every full-materialization point (sort
1181 /// buffer, join build side, GROUP BY hash table, IN-list).
1182 pub(super) fn charge_rows(&self, rows: &[Vec<Value>]) -> Result<(), QueryError> {
1183 let mut total = 0usize;
1184 let mut cancel = crate::cancel::CancelCheck::new();
1185 for row in rows {
1186 cancel.tick()?;
1187 total = total.saturating_add(mem_budget::estimate_row_size(row));
1188 }
1189 mem_budget::charge(total, self.query_memory_limit)
1190 }
1191
1192 /// Charge a materialized IN-list (the literal expressions pulled out of an
1193 /// uncorrelated `IN (subquery)`) against the current per-query budget.
1194 /// Each item is conservatively sized at the `Expr` slot plus, for string
1195 /// literals, the owned heap bytes.
1196 pub(super) fn charge_in_list(&self, list: &[crate::ast::Expr]) -> Result<(), QueryError> {
1197 let base = std::mem::size_of::<crate::ast::Expr>();
1198 let mut total = std::mem::size_of::<Vec<crate::ast::Expr>>();
1199 let mut cancel = crate::cancel::CancelCheck::new();
1200 for item in list {
1201 cancel.tick()?;
1202 total = total.saturating_add(base);
1203 if let crate::ast::Expr::Literal(crate::ast::Literal::String(s)) = item {
1204 total = total.saturating_add(s.capacity());
1205 }
1206 }
1207 mem_budget::charge(total, self.query_memory_limit)
1208 }
1209
1210 /// Dispatch to the requested query frontend.
1211 pub fn execute_with_dialect(
1212 &mut self,
1213 dialect: QueryDialect,
1214 input: &str,
1215 ) -> Result<QueryResult, QueryError> {
1216 match dialect {
1217 QueryDialect::PowQL => self.execute_powql(input),
1218 QueryDialect::Sql => self.execute_sql(input),
1219 }
1220 }
1221
1222 /// Read-only variant of [`Engine::execute_with_dialect`].
1223 pub fn execute_readonly_with_dialect(
1224 &self,
1225 dialect: QueryDialect,
1226 input: &str,
1227 ) -> Result<QueryResult, QueryError> {
1228 match dialect {
1229 QueryDialect::PowQL => self.execute_powql_readonly(input),
1230 QueryDialect::Sql => self.execute_sql_readonly(input),
1231 }
1232 }
1233
1234 /// The statement boundary. An autocommit statement is durable before it
1235 /// returns; inside an explicit transaction the boundary belongs to
1236 /// `commit`, so nothing is flushed here. Flushing per statement inside a
1237 /// transaction buys no durability (an unfinished transaction is rolled
1238 /// back on replay) and costs one fsync per row.
1239 fn commit_statement(&mut self) -> Result<(), QueryError> {
1240 if self.in_transaction {
1241 return Ok(());
1242 }
1243 self.catalog
1244 .commit_autocommit()
1245 .map_err(QueryError::from_storage_io)
1246 }
1247
1248 /// Parse + plan + execute a PowQL query.
1249 ///
1250 /// # Examples
1251 ///
1252 /// ```
1253 /// use powdb_query::executor::Engine;
1254 /// use powdb_query::result::QueryResult;
1255 ///
1256 /// let dir = tempfile::tempdir().unwrap();
1257 /// let mut engine = Engine::new(dir.path()).unwrap();
1258 ///
1259 /// // Create a table and insert a row.
1260 /// engine.execute_powql("type User { required name: str, age: int }").unwrap();
1261 /// engine.execute_powql(r#"insert User { name := "Alice", age := 30 }"#).unwrap();
1262 ///
1263 /// // Query rows back.
1264 /// let result = engine.execute_powql("User").unwrap();
1265 /// assert_eq!(result.row_count(), 1);
1266 /// ```
1267 ///
1268 /// Mission D6 — tracing collapse: the previous implementation ran 4
1269 /// `Instant::now()` + 3 `elapsed().as_micros()` calls + formatted an
1270 /// `info!` span on every query, even when tracing was disabled. On a
1271 /// sub-microsecond `point_lookup_indexed` call that overhead was
1272 /// 100-200ns — 20%+ of the whole query. We now measure time only when
1273 /// INFO is actually enabled via `tracing::enabled!`, and we moved the
1274 /// noisy `debug!(?plan)` line behind the same gate so the Debug
1275 /// formatter can't run unconditionally either.
1276 ///
1277 /// Mission D9 — plan cache: on the hot path we canonicalise the query
1278 /// text (lex + FNV-1a hash with literal values stripped), check the
1279 /// cache, and on a hit substitute the new literals into a clone of the
1280 /// cached plan. This skips re-lexing, re-parsing, and re-planning —
1281 /// around 3μs per call on bench workloads. On a miss we plan as before
1282 /// and insert the plan under its canonical hash.
1283 pub fn execute_powql(&mut self, input: &str) -> Result<QueryResult, QueryError> {
1284 self.run_statement(|engine| engine.execute_powql_inner(input))
1285 }
1286
1287 fn execute_powql_inner(&mut self, input: &str) -> Result<QueryResult, QueryError> {
1288 if self.read_only {
1289 // Snapshot-serving mode: run reads through the read-only executor and
1290 // turn the "this statement writes" sentinel into the terminal
1291 // ReadonlyMode error. No mutation ever reaches disk.
1292 return to_readonly_terminal(self.execute_powql_readonly(input));
1293 }
1294 // WS2: each *outermost* statement starts with the full memory
1295 // allowance. The guard holds the reentrancy depth so a nested
1296 // `execute_powql` (e.g. a view's source query) does not reset the
1297 // outer frame's accounting mid-statement.
1298 let _budget = self.enter_memory_budget();
1299 // A token may be cancelled before execution starts (for example, EOF
1300 // detected while this job was waiting for the engine lock). Check once
1301 // at the statement boundary so even point operations with no long loop
1302 // honor cancellation before they can mutate state.
1303 crate::cancel::check()?;
1304 // Hot path: tracing disabled. Zero syscalls, zero formatting.
1305 if !tracing::enabled!(Level::INFO) {
1306 // D9: try the plan cache first. Canonicalisation lexes the
1307 // query once; on a hit we skip the parser and planner entirely.
1308 if let Ok((hash, canonical, literals)) = canonicalize_with_form(input) {
1309 let cached = self
1310 .plan_cache
1311 .lock()
1312 .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
1313 .get_with_substitution(hash, &canonical, &literals);
1314 if let Some(plan) = cached {
1315 let plan = self.lower(&plan)?;
1316 let result = self.execute_lowered(&plan);
1317 return result;
1318 }
1319 // Miss — plan, insert, execute.
1320 let (raw, plan) = self.plan_text_and_lower(input)?;
1321 self.plan_cache
1322 .lock()
1323 .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
1324 .insert(hash, canonical, raw, literals.len());
1325 let result = self.execute_lowered(&plan);
1326 return result;
1327 }
1328 // Lex error — fall through to the planner so the caller gets a
1329 // consistent error shape.
1330 let (_, plan) = self.plan_text_and_lower(input)?;
1331 let result = self.execute_lowered(&plan);
1332 return result;
1333 }
1334
1335 // Instrumented path — only taken under explicit tracing subscribers.
1336 let total_start = Instant::now();
1337 let plan_start = Instant::now();
1338 // `plan_us` now covers planning AND lowering. They are one step from
1339 // the caller's point of view (lowering is not optional, an unlowered
1340 // plan answers differently), and keeping them one call is what keeps
1341 // the planner reachable from a single place.
1342 let (_, plan) = self.plan_text_and_lower(input).inspect_err(|err| {
1343 error!(query = %input, error = %err, "query plan failed");
1344 })?;
1345 let plan_us = plan_start.elapsed().as_micros();
1346
1347 let exec_start = Instant::now();
1348 let result = self.execute_lowered(&plan);
1349 let exec_us = exec_start.elapsed().as_micros();
1350
1351 let total_us = total_start.elapsed().as_micros();
1352 match &result {
1353 Ok(r) => {
1354 info!(
1355 query = %input,
1356 plan_us = plan_us,
1357 exec_us = exec_us,
1358 total_us = total_us,
1359 rows = r.row_count(),
1360 "query ok"
1361 );
1362 }
1363 Err(e) => {
1364 error!(
1365 query = %input,
1366 plan_us = plan_us,
1367 exec_us = exec_us,
1368 error = %e,
1369 "query failed"
1370 );
1371 }
1372 }
1373 result
1374 }
1375
1376 /// Parse + plan + execute a SQL query through the SQL frontend.
1377 ///
1378 /// SQL is lowered to the existing PowDB AST and to canonical PowQL text.
1379 /// The canonical PowQL text is used as the plan-cache key, so equivalent
1380 /// SQL and PowQL spellings share cached plans.
1381 pub fn execute_sql(&mut self, input: &str) -> Result<QueryResult, QueryError> {
1382 self.run_statement(|engine| engine.execute_sql_inner(input))
1383 }
1384
1385 fn execute_sql_inner(&mut self, input: &str) -> Result<QueryResult, QueryError> {
1386 if self.read_only {
1387 return to_readonly_terminal(self.execute_sql_readonly(input));
1388 }
1389 let _budget = self.enter_memory_budget();
1390 crate::cancel::check()?;
1391 let parsed = crate::sql::parse_sql_with_canonical(input)
1392 .map_err(|e| QueryError::Parse(e.to_string()))?;
1393
1394 if !tracing::enabled!(Level::INFO) {
1395 if let Ok((hash, canonical, literals)) = canonicalize_with_form(&parsed.canonical_powql)
1396 {
1397 let hash = if crate::sql::statement_has_aggregate(&parsed.statement) {
1398 sql_raw_cache_hash(hash)
1399 } else {
1400 hash
1401 };
1402 let cached = self
1403 .plan_cache
1404 .lock()
1405 .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
1406 .get_with_substitution(hash, &canonical, &literals);
1407 if let Some(plan) = cached {
1408 let plan = self.lower(&plan)?;
1409 let result = self.execute_lowered(&plan);
1410 return result;
1411 }
1412
1413 let (raw, plan) = self.plan_and_lower_cacheable(parsed.statement)?;
1414 self.plan_cache
1415 .lock()
1416 .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
1417 .insert(hash, canonical, raw, literals.len());
1418 let result = self.execute_lowered(&plan);
1419 return result;
1420 }
1421 }
1422
1423 let plan = self.plan_and_lower(parsed.statement)?;
1424 self.execute_lowered(&plan)
1425 }
1426
1427 /// Read-only variant of [`Engine::execute_sql`].
1428 pub fn execute_sql_readonly(&self, input: &str) -> Result<QueryResult, QueryError> {
1429 self.run_read_statement(|engine| engine.execute_sql_readonly_inner(input))
1430 }
1431
1432 fn execute_sql_readonly_inner(&self, input: &str) -> Result<QueryResult, QueryError> {
1433 self.ensure_read_allowed()?;
1434 let _budget = self.enter_memory_budget();
1435 crate::cancel::check()?;
1436 let parsed = crate::sql::parse_sql_with_canonical(input)
1437 .map_err(|e| QueryError::Parse(e.to_string()))?;
1438 if !is_read_only_statement(&parsed.statement) {
1439 return Err(QueryError::ReadonlyNeedsWrite);
1440 }
1441
1442 if let Ok((hash, canonical, literals)) = canonicalize_with_form(&parsed.canonical_powql) {
1443 let hash = if crate::sql::statement_has_aggregate(&parsed.statement) {
1444 sql_raw_cache_hash(hash)
1445 } else {
1446 hash
1447 };
1448 let cached = self
1449 .plan_cache
1450 .lock()
1451 .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
1452 .get_with_substitution(hash, &canonical, &literals);
1453 if let Some(plan) = cached {
1454 let plan = self.lower(&plan)?;
1455 return self.execute_plan_readonly(&plan);
1456 }
1457 let (raw, plan) = self.plan_and_lower_cacheable(parsed.statement)?;
1458 self.plan_cache
1459 .lock()
1460 .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
1461 .insert(hash, canonical, raw, literals.len());
1462 return self.execute_plan_readonly(&plan);
1463 }
1464
1465 let plan = self.plan_and_lower(parsed.statement)?;
1466 self.execute_plan_readonly(&plan)
1467 }
1468
1469 /// Execute PowQL with `$N` placeholders bound to positional `params`.
1470 ///
1471 /// Task 4: parameters are substituted as literal *tokens* before
1472 /// parsing (see [`crate::parser::parse_with_params`]), so untrusted
1473 /// input can never change the query's shape. This path deliberately
1474 /// **bypasses the plan cache** — template caching is a follow-up — and
1475 /// otherwise mirrors the non-cached tail of [`Engine::execute_powql`].
1476 pub fn execute_powql_with_params(
1477 &mut self,
1478 input: &str,
1479 params: &[crate::ast::ParamValue],
1480 ) -> Result<QueryResult, QueryError> {
1481 self.run_statement(|engine| engine.execute_powql_with_params_inner(input, params))
1482 }
1483
1484 fn execute_powql_with_params_inner(
1485 &mut self,
1486 input: &str,
1487 params: &[crate::ast::ParamValue],
1488 ) -> Result<QueryResult, QueryError> {
1489 if self.read_only {
1490 return to_readonly_terminal(self.execute_powql_readonly_with_params(input, params));
1491 }
1492 let _budget = self.enter_memory_budget();
1493 crate::cancel::check()?;
1494 let stmt = crate::parser::parse_with_params(input, params)
1495 .map_err(|e| QueryError::Parse(e.to_string()))?;
1496 let plan = self.plan_and_lower(stmt)?;
1497 self.execute_lowered(&plan)
1498 }
1499
1500 /// Read-only variant of [`Engine::execute_powql_with_params`].
1501 ///
1502 /// Mirrors [`Engine::execute_powql_readonly`]: parses with bound
1503 /// params, rejects any write statement with
1504 /// [`QueryError::ReadonlyNeedsWrite`] so the caller can escalate to the
1505 /// write lock, then executes under a shared borrow. No plan-cache
1506 /// interaction.
1507 pub fn execute_powql_readonly_with_params(
1508 &self,
1509 input: &str,
1510 params: &[crate::ast::ParamValue],
1511 ) -> Result<QueryResult, QueryError> {
1512 self.run_read_statement(|engine| {
1513 engine.execute_powql_readonly_with_params_inner(input, params)
1514 })
1515 }
1516
1517 fn execute_powql_readonly_with_params_inner(
1518 &self,
1519 input: &str,
1520 params: &[crate::ast::ParamValue],
1521 ) -> Result<QueryResult, QueryError> {
1522 self.ensure_read_allowed()?;
1523 let _budget = self.enter_memory_budget();
1524 crate::cancel::check()?;
1525 let stmt = crate::parser::parse_with_params(input, params)
1526 .map_err(|e| QueryError::Parse(e.to_string()))?;
1527 if !is_read_only_statement(&stmt) {
1528 return Err(QueryError::ReadonlyNeedsWrite);
1529 }
1530 let plan = self.plan_and_lower(stmt)?;
1531 self.execute_plan_readonly(&plan)
1532 }
1533
1534 /// Cancellation-aware variant of [`Engine::execute_powql`]. Installs
1535 /// `cancel` as the current thread's cancellation token for the duration of
1536 /// the statement, so cancellable read and mutation-target discovery loops
1537 /// poll it. Mutation application checks once before its first write, then
1538 /// finishes without polling because the engine has no statement savepoint
1539 /// with which to undo a written prefix. The base methods also honor an
1540 /// already installed token; a caller with no token (embedded/direct use)
1541 /// never cancels.
1542 pub fn execute_powql_with_cancel(
1543 &mut self,
1544 input: &str,
1545 cancel: Arc<crate::cancel::ExecCancel>,
1546 ) -> Result<QueryResult, QueryError> {
1547 let _cancel_guard = crate::cancel::install(cancel);
1548 self.execute_powql(input)
1549 }
1550
1551 /// Cancellation-aware variant of [`Engine::execute_sql`].
1552 pub fn execute_sql_with_cancel(
1553 &mut self,
1554 input: &str,
1555 cancel: Arc<crate::cancel::ExecCancel>,
1556 ) -> Result<QueryResult, QueryError> {
1557 let _cancel_guard = crate::cancel::install(cancel);
1558 self.execute_sql(input)
1559 }
1560
1561 /// Cancellation-aware variant of [`Engine::execute_powql_readonly`].
1562 pub fn execute_powql_readonly_with_cancel(
1563 &self,
1564 input: &str,
1565 cancel: Arc<crate::cancel::ExecCancel>,
1566 ) -> Result<QueryResult, QueryError> {
1567 let _cancel_guard = crate::cancel::install(cancel);
1568 self.execute_powql_readonly(input)
1569 }
1570
1571 /// Cancellation-aware variant of [`Engine::execute_sql_readonly`].
1572 pub fn execute_sql_readonly_with_cancel(
1573 &self,
1574 input: &str,
1575 cancel: Arc<crate::cancel::ExecCancel>,
1576 ) -> Result<QueryResult, QueryError> {
1577 let _cancel_guard = crate::cancel::install(cancel);
1578 self.execute_sql_readonly(input)
1579 }
1580
1581 /// Cancellation-aware variant of [`Engine::execute_powql_with_params`].
1582 pub fn execute_powql_with_params_and_cancel(
1583 &mut self,
1584 input: &str,
1585 params: &[crate::ast::ParamValue],
1586 cancel: Arc<crate::cancel::ExecCancel>,
1587 ) -> Result<QueryResult, QueryError> {
1588 let _cancel_guard = crate::cancel::install(cancel);
1589 self.execute_powql_with_params(input, params)
1590 }
1591
1592 /// Cancellation-aware variant of [`Engine::execute_powql_readonly_with_params`].
1593 pub fn execute_powql_readonly_with_params_and_cancel(
1594 &self,
1595 input: &str,
1596 params: &[crate::ast::ParamValue],
1597 cancel: Arc<crate::cancel::ExecCancel>,
1598 ) -> Result<QueryResult, QueryError> {
1599 let _cancel_guard = crate::cancel::install(cancel);
1600 self.execute_powql_readonly_with_params(input, params)
1601 }
1602
1603 /// Plan cache stats — useful for benches and debugging.
1604 pub fn plan_cache_stats(&self) -> (u64, u64, usize) {
1605 let cache = self.plan_cache.lock().unwrap_or_else(|e| e.into_inner());
1606 (cache.hits, cache.misses, cache.len())
1607 }
1608
1609 /// Mission infra-1: read-only entry point.
1610 ///
1611 /// Parses + plans + executes a PowQL query using only a shared borrow
1612 /// on the engine. Rejects any statement that would mutate state
1613 /// (Insert/Update/Delete/CreateTable/AlterTable/DropTable/CreateView/
1614 /// RefreshView/DropView) by returning [`READONLY_NEEDS_WRITE`] so the
1615 /// caller can escalate to the write lock.
1616 ///
1617 /// Also returns [`READONLY_NEEDS_WRITE`] if a materialized view in the
1618 /// query is dirty — refreshing one requires `&mut self`, so the caller
1619 /// must retake the write lock for the first refresh.
1620 ///
1621 /// This method is the concurrent-read fast path behind
1622 /// `Arc<RwLock<Engine>>`: multiple threads can call it simultaneously
1623 /// under a shared `.read()` lock and each will scan independently.
1624 pub fn execute_powql_readonly(&self, input: &str) -> Result<QueryResult, QueryError> {
1625 self.run_read_statement(|engine| engine.execute_powql_readonly_inner(input))
1626 }
1627
1628 fn execute_powql_readonly_inner(&self, input: &str) -> Result<QueryResult, QueryError> {
1629 self.ensure_read_allowed()?;
1630 // WS2: each *outermost* statement starts with the full memory
1631 // allowance. The guard holds the reentrancy depth so a nested
1632 // `execute_powql*` does not reset the outer frame's accounting.
1633 let _budget = self.enter_memory_budget();
1634 crate::cancel::check()?;
1635 // Parse the statement first so we can classify read vs. write
1636 // without touching the catalog. This is the same lex+parse cost
1637 // the hot path would pay anyway.
1638 let stmt = crate::parser::parse(input).map_err(|e| QueryError::Parse(e.to_string()))?;
1639 if !is_read_only_statement(&stmt) {
1640 return Err(QueryError::ReadonlyNeedsWrite);
1641 }
1642
1643 // Try the plan cache first — identical hash scheme to
1644 // `execute_powql` so both paths share cache state. The mutex
1645 // section is just a hashmap lookup + plan clone.
1646 if let Ok((hash, canonical, literals)) = canonicalize_with_form(input) {
1647 let cached = self
1648 .plan_cache
1649 .lock()
1650 .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
1651 .get_with_substitution(hash, &canonical, &literals);
1652 if let Some(plan) = cached {
1653 let plan = self.lower(&plan)?;
1654 return self.execute_plan_readonly(&plan);
1655 }
1656 // Miss: plan + insert + execute. The planner is pure, so this
1657 // is safe from `&self`.
1658 let (raw, plan) = self.plan_and_lower_cacheable(stmt)?;
1659 self.plan_cache
1660 .lock()
1661 .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
1662 .insert(hash, canonical, raw, literals.len());
1663 return self.execute_plan_readonly(&plan);
1664 }
1665 // Lex error — fall through to the planner for a consistent error
1666 // shape (though `parse` above would usually have caught it).
1667 let plan = self.plan_and_lower(stmt)?;
1668 self.execute_plan_readonly(&plan)
1669 }
1670
1671 /// Read-only version of [`Engine::execute_plan`]. Dispatches the
1672 /// read-path plan variants by calling `&self` helpers and errors with
1673 /// [`READONLY_NEEDS_WRITE`] on any write variant. This is the
1674 /// recursion target for composite read plans under the RwLock reader.
1675 ///
1676 /// The dispatch mirrors `execute_plan` for the read branches but does
1677 /// not carry any of the fast-paths that need `&mut self` (e.g. plan-
1678 /// cache mutation on inner subqueries is handled via the shared mutex
1679 /// in [`Engine::execute_powql_readonly`]; in-flight subquery
1680 /// materialisation uses [`Engine::materialize_subqueries_readonly`]).
1681 fn execute_plan_readonly(&self, plan: &LoweredPlan) -> Result<QueryResult, QueryError> {
1682 self.ensure_read_allowed()?;
1683 Self::raise_arith_fault(self.dispatch_readonly(plan.node()))
1684 }
1685
1686 /// The read-path dispatch itself. Takes a bare `&PlanNode` because it is
1687 /// the recursion target: every child of a lowered plan is lowered, so a
1688 /// subtree needs no second wrapper. Reaching it from outside an already
1689 /// lowered tree is what [`Engine::execute_plan_readonly`] exists to
1690 /// prevent, which is why this one is private and unlowered plans cannot
1691 /// name it.
1692 fn dispatch_readonly(&self, plan: &PlanNode) -> Result<QueryResult, QueryError> {
1693 // Detect every dirty materialized-view source before executing any
1694 // branch of the plan. Without this preflight, a join could fully scan
1695 // its clean left input before discovering a dirty right input, then
1696 // repeat that work after the server upgrades to writer admission.
1697 // Alias scans also need this centralized check: they do not pass
1698 // through the SeqScan arm below.
1699 if plan_reads_dirty_view(plan, &self.view_registry) {
1700 return Err(QueryError::ReadonlyNeedsWrite);
1701 }
1702 // Mirror the mutable path: reject a stray aggregate FunctionCall, an
1703 // unknown table or column, a mistyped comparison or JSON path base and
1704 // a negative slice count before evaluating any row (see `dispatch_mut`
1705 // for the rationale).
1706 validate_plan(&self.catalog, plan)?;
1707 match plan {
1708 PlanNode::ExprIndexScan { .. }
1709 | PlanNode::ExprRangeScan { .. }
1710 | PlanNode::OrderedExprIndexScan { .. } => {
1711 if let Some(result) = self.execute_expression_index_plan(plan, None)? {
1712 return Ok(result);
1713 }
1714 let fallback = self.lower(plan)?;
1715 self.execute_plan_readonly(&fallback)
1716 }
1717 PlanNode::SeqScan { table } => {
1718 // Dirty view means we'd need to refresh it — can't do that
1719 // under `&self`. Escalate to the write path.
1720 if self.view_registry.is_dirty(table) {
1721 return Err(QueryError::ReadonlyNeedsWrite);
1722 }
1723 let schema = self
1724 .catalog
1725 .schema(table)
1726 .ok_or_else(|| QueryError::TableNotFound(table.clone()))?
1727 .clone();
1728 let columns: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
1729 // Cooperative cancellation: a full-table scan of a huge table
1730 // must stay stoppable.
1731 let mut cancel = crate::cancel::CancelCheck::new();
1732 let mut rows: Vec<Vec<Value>> = Vec::new();
1733 for item in self.catalog.scan(table).map_err(|e| e.to_string())? {
1734 let (_, row) = item.map_err(crate::result::QueryError::from_storage_io)?;
1735 cancel.tick()?;
1736 rows.push(row);
1737 }
1738 Ok(QueryResult::Rows { columns, rows })
1739 }
1740
1741 PlanNode::AliasScan { table, alias } => {
1742 let schema = self
1743 .catalog
1744 .schema(table)
1745 .ok_or_else(|| QueryError::TableNotFound(table.clone()))?
1746 .clone();
1747 let columns: Vec<String> = schema
1748 .columns
1749 .iter()
1750 .map(|c| format!("{alias}.{}", c.name))
1751 .collect();
1752 let mut cancel = crate::cancel::CancelCheck::new();
1753 let mut rows: Vec<Vec<Value>> = Vec::new();
1754 for item in self.catalog.scan(table).map_err(|e| e.to_string())? {
1755 let (_, row) = item.map_err(crate::result::QueryError::from_storage_io)?;
1756 cancel.tick()?;
1757 rows.push(row);
1758 }
1759 Ok(QueryResult::Rows { columns, rows })
1760 }
1761
1762 PlanNode::NestedProject { input, fields } => {
1763 // Dirty child views were escalated by the preflight above;
1764 // the assembly itself only reads.
1765 // Resolve link traversals against the persistent catalog before
1766 // assembly (like the mutable dispatch), so child tables and
1767 // scalar hop chains are concrete.
1768 let resolved;
1769 let fields: &[crate::plan::NestedProjectField] =
1770 if plan_exec::nested_fields_have_via_link(fields) {
1771 let outer = plan_exec::scan_source_table(input).ok_or_else(|| {
1772 QueryError::Execution(
1773 "link traversal requires a plain aliased table scan as its parent"
1774 .into(),
1775 )
1776 })?;
1777 resolved = self.resolve_nested_via_links(fields, outer)?;
1778 &resolved
1779 } else {
1780 fields
1781 };
1782 let parent = self.dispatch_readonly(input)?;
1783 self.execute_nested_project(parent, fields)
1784 }
1785
1786 PlanNode::IndexScan { table, column, key } => {
1787 let schema = self
1788 .catalog
1789 .schema(table)
1790 .ok_or_else(|| QueryError::TableNotFound(table.clone()))?
1791 .clone();
1792 let columns: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
1793 let key_value = literal_to_value(key)?;
1794 let tbl = self
1795 .catalog
1796 .get_table(table)
1797 .ok_or_else(|| QueryError::TableNotFound(table.clone()))?;
1798
1799 if tbl.has_index(column) {
1800 // Use index_lookup_all to handle both unique and
1801 // non-unique indexes — returns all matching RowIds.
1802 let rids = tbl.index_lookup_all(column, &key_value);
1803 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(rids.len());
1804 let mut cancel = crate::cancel::CancelCheck::new();
1805 for rid in rids {
1806 cancel.tick()?;
1807 // Overflow safety (P0-3/P0-4): `tbl.get` reassembles
1808 // spilled columns (the old `heap.get` + `decode_row`
1809 // returned Empty / wrapped a >= 64KB value).
1810 if let Some(row) = tbl.get(rid).map_err(QueryError::from_storage_io)? {
1811 rows.push(row);
1812 }
1813 }
1814 return Ok(QueryResult::Rows { columns, rows });
1815 }
1816
1817 // No index: synthetic eq predicate + compiled scan.
1818 // Overflow safety (P0-4/P1): v2-capable tables use the decoded
1819 // last-resort scan below (raw scan drops/mis-reads spilled cols).
1820 let fast = FastLayout::new(&schema);
1821 let synth_pred = Expr::BinaryOp(
1822 Box::new(Expr::Field(column.clone())),
1823 BinOp::Eq,
1824 Box::new(key.clone()),
1825 );
1826 if !tbl.has_overflow_rows() {
1827 if let Some(compiled) = self.compile_predicate_unless_forced(
1828 "readonly:index-scan-scan-fallback:predicate",
1829 &synth_pred,
1830 &columns,
1831 &fast,
1832 &schema,
1833 ) {
1834 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
1835 for_each_row_raw_cancellable(&self.catalog, table, |_rid, data| {
1836 if compiled(data) {
1837 rows.push(decode_row(&schema, data));
1838 }
1839 })?;
1840 return Ok(QueryResult::Rows { columns, rows });
1841 }
1842 }
1843
1844 // Last resort: slow eq-check.
1845 let col_idx =
1846 schema
1847 .column_index(column)
1848 .ok_or_else(|| QueryError::ColumnNotFound {
1849 table: String::new(),
1850 column: column.clone(),
1851 })?;
1852 let mut cancel = crate::cancel::CancelCheck::new();
1853 let mut rows: Vec<Vec<Value>> = Vec::new();
1854 for item in tbl.scan() {
1855 let (_, row) = item.map_err(crate::result::QueryError::from_storage_io)?;
1856 cancel.tick()?;
1857 if row[col_idx] == key_value {
1858 rows.push(row);
1859 }
1860 }
1861 Ok(QueryResult::Rows { columns, rows })
1862 }
1863
1864 PlanNode::RangeScan {
1865 table,
1866 column,
1867 start,
1868 end,
1869 } => {
1870 let tbl = self
1871 .catalog
1872 .get_table(table)
1873 .ok_or_else(|| QueryError::TableNotFound(table.clone()))?;
1874 let columns: Vec<String> = tbl
1875 .schema()
1876 .columns
1877 .iter()
1878 .map(|c| c.name.clone())
1879 .collect();
1880 let schema = tbl.schema().clone();
1881
1882 let start_val = match start {
1883 Some((expr, _)) => Some(literal_to_value(expr)?),
1884 None => None,
1885 };
1886 let end_val = match end {
1887 Some((expr, _)) => Some(literal_to_value(expr)?),
1888 None => None,
1889 };
1890 let start_inclusive = start.as_ref().map(|(_, inc)| *inc).unwrap_or(true);
1891 let end_inclusive = end.as_ref().map(|(_, inc)| *inc).unwrap_or(true);
1892
1893 // Range scans only use the btree fast path for unique indexes.
1894 // Non-unique indexes store composite keys that don't compare
1895 // directly against raw column values.
1896 if tbl.is_index_unique(column) == Some(true) {
1897 if let Some(btree) = tbl.index(column) {
1898 let hits: Vec<(Value, RowId)> = match (&start_val, &end_val) {
1899 (Some(s), Some(e)) => btree.range(s, e).collect(),
1900 (Some(s), None) => btree.range_from(s),
1901 (None, Some(e)) => btree.range_to(e),
1902 (None, None) => {
1903 // Unbounded both sides — equivalent to seq scan.
1904 let mut cancel = crate::cancel::CancelCheck::new();
1905 let mut rows: Vec<Vec<Value>> = Vec::new();
1906 for item in tbl.scan() {
1907 let (_, row) =
1908 item.map_err(crate::result::QueryError::from_storage_io)?;
1909 cancel.tick()?;
1910 rows.push(row);
1911 }
1912 return Ok(QueryResult::Rows { columns, rows });
1913 }
1914 };
1915 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(hits.len());
1916 let mut cancel = crate::cancel::CancelCheck::new();
1917 for (key, rid) in hits {
1918 cancel.tick()?;
1919 // Filter for exclusive bounds.
1920 if !start_inclusive {
1921 if let Some(ref s) = start_val {
1922 if &key == s {
1923 continue;
1924 }
1925 }
1926 }
1927 if !end_inclusive {
1928 if let Some(ref e) = end_val {
1929 if &key == e {
1930 continue;
1931 }
1932 }
1933 }
1934 // Overflow safety (P0-3): reassemble spilled cols.
1935 if let Some(row) = tbl.get(rid).map_err(QueryError::from_storage_io)? {
1936 rows.push(row);
1937 }
1938 }
1939 return Ok(QueryResult::Rows { columns, rows });
1940 }
1941 }
1942
1943 // Fallback: no index — synthesize the range predicate and scan.
1944 // Overflow safety (P0-4): v2-capable tables use the decoded
1945 // last-resort scan below.
1946 let fast = FastLayout::new(&schema);
1947 let synth = synthesize_range_predicate(column, start, end);
1948 if !tbl.has_overflow_rows() {
1949 if let Some(compiled) = self.compile_predicate_unless_forced(
1950 "readonly:range-scan-scan-fallback:predicate",
1951 &synth,
1952 &columns,
1953 &fast,
1954 &schema,
1955 ) {
1956 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
1957 for_each_row_raw_cancellable(&self.catalog, table, |_rid, data| {
1958 if compiled(data) {
1959 rows.push(decode_row(&schema, data));
1960 }
1961 })?;
1962 return Ok(QueryResult::Rows { columns, rows });
1963 }
1964 }
1965
1966 // Last resort: decoded row eval.
1967 let col_idx =
1968 schema
1969 .column_index(column)
1970 .ok_or_else(|| QueryError::ColumnNotFound {
1971 table: String::new(),
1972 column: column.clone(),
1973 })?;
1974 let mut cancel = crate::cancel::CancelCheck::new();
1975 let mut rows: Vec<Vec<Value>> = Vec::new();
1976 for item in tbl.scan() {
1977 let (_, row) = item.map_err(crate::result::QueryError::from_storage_io)?;
1978 cancel.tick()?;
1979 if range_matches(
1980 &row[col_idx],
1981 &start_val,
1982 start_inclusive,
1983 &end_val,
1984 end_inclusive,
1985 ) {
1986 rows.push(row);
1987 }
1988 }
1989 Ok(QueryResult::Rows { columns, rows })
1990 }
1991
1992 PlanNode::Filter { input, predicate } => {
1993 // Materialise subqueries using the `&self` variant.
1994 // Uncorrelated subqueries are replaced with InList/Bool;
1995 // correlated ones are left as InSubquery/ExistsSubquery
1996 // for per-row materialisation below.
1997 let materialized;
1998 let predicate = if contains_subquery(predicate) {
1999 let outer_columns = self.plan_output_columns(input);
2000 materialized =
2001 self.materialize_subqueries_readonly(predicate, outer_columns.as_deref())?;
2002 &materialized
2003 } else {
2004 predicate
2005 };
2006
2007 // Correlated subquery path: per-row materialisation.
2008 if contains_subquery(predicate) {
2009 let result = self.dispatch_readonly(input)?;
2010 return match result {
2011 QueryResult::Rows { columns, rows } => {
2012 let mut filtered = Vec::new();
2013 // Cooperative cancellation: this runs a subquery per
2014 // outer row, so a large outer scan must stay stoppable.
2015 let mut cancel = crate::cancel::CancelCheck::new();
2016 for row in rows {
2017 cancel.tick()?;
2018 let row_pred = self.materialize_correlated_for_row_readonly(
2019 predicate, &row, &columns,
2020 )?;
2021 if eval_predicate(&row_pred, &row, &columns) {
2022 filtered.push(row);
2023 }
2024 }
2025 Ok(QueryResult::Rows {
2026 columns,
2027 rows: filtered,
2028 })
2029 }
2030 _ => Err("filter requires row input".into()),
2031 };
2032 }
2033
2034 // Lane A fast path: Filter over an equality-driven index scan
2035 // (mirrors the mutable path). Pure `&self`, so it is shared.
2036 if matches!(
2037 input.as_ref(),
2038 PlanNode::IndexScan { .. } | PlanNode::ExprIndexScan { .. }
2039 ) {
2040 if let Some(result) = self.try_filter_index_residual_fast(input, predicate)? {
2041 return Ok(result);
2042 }
2043 }
2044
2045 // Fused Filter+SeqScan fast path.
2046 // Overflow safety (P0-4/P1): v2-capable tables fall through to
2047 // the decoded general path below.
2048 if let PlanNode::SeqScan { table } = input.as_ref() {
2049 if !self.catalog.table_has_overflow(table)
2050 && !self.generic_path_forced("readonly:filter-seqscan-raw")
2051 {
2052 if self.view_registry.is_dirty(table) {
2053 return Err(QueryError::ReadonlyNeedsWrite);
2054 }
2055 let schema = self
2056 .catalog
2057 .schema(table)
2058 .ok_or_else(|| QueryError::TableNotFound(table.clone()))?
2059 .clone();
2060 let columns: Vec<String> =
2061 schema.columns.iter().map(|c| c.name.clone()).collect();
2062 let fast = FastLayout::new(&schema);
2063 let row_layout = RowLayout::new(&schema);
2064 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
2065
2066 // Cooperative cancellation: full-table compiled/selective
2067 // predicate scan must stay stoppable (see the write-path
2068 // Filter fast path for the same pattern).
2069 let mut cancel = crate::cancel::CancelCheck::new();
2070 let mut cancel_err: Option<QueryError> = None;
2071 if let Some(compiled) = self.compile_predicate_unless_forced(
2072 "readonly:filter-seqscan:predicate",
2073 predicate,
2074 &columns,
2075 &fast,
2076 &schema,
2077 ) {
2078 self.catalog
2079 .try_for_each_row_raw(table, |_rid, data| {
2080 if let Err(e) = cancel.tick() {
2081 cancel_err = Some(e);
2082 return std::ops::ControlFlow::Break(());
2083 }
2084 if compiled(data) {
2085 rows.push(decode_row(&schema, data));
2086 }
2087 std::ops::ControlFlow::Continue(())
2088 })
2089 .map_err(QueryError::from_storage_io)?;
2090 } else {
2091 let pred_cols = predicate_column_indices_json(predicate, &columns);
2092 self.catalog
2093 .try_for_each_row_raw(table, |_rid, data| {
2094 if let Err(e) = cancel.tick() {
2095 cancel_err = Some(e);
2096 return std::ops::ControlFlow::Break(());
2097 }
2098 let pred_row =
2099 decode_selective(&schema, &row_layout, data, &pred_cols);
2100 if eval_predicate(predicate, &pred_row, &columns) {
2101 rows.push(decode_row(&schema, data));
2102 }
2103 std::ops::ControlFlow::Continue(())
2104 })
2105 .map_err(QueryError::from_storage_io)?;
2106 }
2107 if let Some(e) = cancel_err {
2108 return Err(e);
2109 }
2110
2111 return Ok(QueryResult::Rows { columns, rows });
2112 }
2113 }
2114
2115 // General path.
2116 let result = self.dispatch_readonly(input)?;
2117 match result {
2118 QueryResult::Rows { columns, rows } => {
2119 let mut cancel = crate::cancel::CancelCheck::new();
2120 let mut filtered: Vec<Vec<Value>> = Vec::new();
2121 for row in rows {
2122 cancel.tick()?;
2123 if eval_predicate(predicate, &row, &columns) {
2124 filtered.push(row);
2125 }
2126 }
2127 Ok(QueryResult::Rows {
2128 columns,
2129 rows: filtered,
2130 })
2131 }
2132 _ => Err("filter requires row input".into()),
2133 }
2134 }
2135
2136 PlanNode::Project { input, fields } => {
2137 if matches!(
2138 input.as_ref(),
2139 PlanNode::ExprIndexScan { .. }
2140 | PlanNode::ExprRangeScan { .. }
2141 | PlanNode::OrderedExprIndexScan { .. }
2142 ) {
2143 if let Some(result) = self.execute_expression_index_plan(input, Some(fields))? {
2144 return Ok(result);
2145 }
2146 }
2147 // Fast path: Project over IndexScan. Avoids full-row decode
2148 // by calling decode_column only for projected fields.
2149 if let PlanNode::IndexScan { table, column, key } = input.as_ref() {
2150 let key_value = literal_to_value(key)?;
2151 let tbl = self
2152 .catalog
2153 .get_table(table)
2154 .ok_or_else(|| QueryError::TableNotFound(table.clone()))?;
2155 let schema = tbl.schema();
2156
2157 let proj_columns: Vec<String> = fields
2158 .iter()
2159 .map(|f| {
2160 f.alias
2161 .clone()
2162 .unwrap_or_else(|| projection_output_name(&f.expr))
2163 })
2164 .collect();
2165
2166 let proj_indices: Vec<usize> = fields
2167 .iter()
2168 .filter_map(|f| {
2169 if let Expr::Field(name) = &f.expr {
2170 schema.column_index(name)
2171 } else {
2172 None
2173 }
2174 })
2175 .collect();
2176
2177 // Plain-field projections only; a computed projection
2178 // (e.g. `length(.v)`) falls through to the generic
2179 // expression-evaluating path (its column is otherwise
2180 // dropped — proj_indices only collects Fields).
2181 let all_plain_fields = fields.iter().all(|f| matches!(f.expr, Expr::Field(_)));
2182 if tbl.has_index(column)
2183 && all_plain_fields
2184 && !self.generic_path_forced("readonly:project-over-index-scan")
2185 {
2186 let rids = tbl.index_lookup_all(column, &key_value);
2187 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(rids.len());
2188 let mut cancel = crate::cancel::CancelCheck::new();
2189 for rid in rids {
2190 cancel.tick()?;
2191 // Overflow safety (P0-3/P0-4): reassemble via
2192 // `tbl.get` so spilled projected columns return
2193 // their value, not Empty / a wrapped >= 64KB blob.
2194 if let Some(full) = tbl.get(rid).map_err(QueryError::from_storage_io)? {
2195 let row: Vec<Value> =
2196 proj_indices.iter().map(|&ci| full[ci].clone()).collect();
2197 rows.push(row);
2198 }
2199 }
2200 return Ok(QueryResult::Rows {
2201 columns: proj_columns,
2202 rows,
2203 });
2204 }
2205 }
2206
2207 // Fast paths over Limit(Sort(...)) / Limit(Filter(...)) / Limit(SeqScan).
2208 if let PlanNode::Limit {
2209 input: inner,
2210 count: limit_expr,
2211 } = input.as_ref()
2212 {
2213 if let PlanNode::Sort {
2214 input: sort_input,
2215 keys,
2216 } = inner.as_ref()
2217 {
2218 if keys.len() == 1 {
2219 if let (Expr::Field(sort_field), Some(limit)) =
2220 (&keys[0].expr, literal_limit(limit_expr))
2221 {
2222 let descending = keys[0].descending;
2223 let (table_opt, pred_opt): (Option<&str>, Option<&Expr>) =
2224 match sort_input.as_ref() {
2225 PlanNode::SeqScan { table } => (Some(table.as_str()), None),
2226 PlanNode::Filter {
2227 input: fi,
2228 predicate,
2229 } => {
2230 if let PlanNode::SeqScan { table } = fi.as_ref() {
2231 (Some(table.as_str()), Some(predicate))
2232 } else {
2233 (None, None)
2234 }
2235 }
2236 _ => (None, None),
2237 };
2238 if let Some(table) = table_opt {
2239 if let Some(result) = self.project_filter_sort_limit_fast(
2240 table, fields, sort_field, descending, limit, pred_opt,
2241 )? {
2242 return Ok(result);
2243 }
2244 }
2245 }
2246 }
2247 }
2248 if let PlanNode::Filter {
2249 input: fi,
2250 predicate,
2251 } = inner.as_ref()
2252 {
2253 if let (PlanNode::SeqScan { table }, Some(limit)) =
2254 (fi.as_ref(), literal_limit(limit_expr))
2255 {
2256 if let Some(result) = self.project_filter_limit_fast(
2257 table,
2258 fields,
2259 limit,
2260 Some(predicate),
2261 )? {
2262 return Ok(result);
2263 }
2264 }
2265 }
2266 if let (PlanNode::SeqScan { table }, Some(limit)) =
2267 (inner.as_ref(), literal_limit(limit_expr))
2268 {
2269 if let Some(result) =
2270 self.project_filter_limit_fast(table, fields, limit, None)?
2271 {
2272 return Ok(result);
2273 }
2274 }
2275 }
2276
2277 // Project(Filter(SeqScan)) without Limit.
2278 if let PlanNode::Filter {
2279 input: fi,
2280 predicate,
2281 } = input.as_ref()
2282 {
2283 if let PlanNode::SeqScan { table } = fi.as_ref() {
2284 if let Some(result) = self.project_filter_limit_fast(
2285 table,
2286 fields,
2287 usize::MAX,
2288 Some(predicate),
2289 )? {
2290 return Ok(result);
2291 }
2292 }
2293 }
2294
2295 // Project(SeqScan) without Filter or Limit.
2296 if let PlanNode::SeqScan { table } = input.as_ref() {
2297 if let Some(result) =
2298 self.project_filter_limit_fast(table, fields, usize::MAX, None)?
2299 {
2300 return Ok(result);
2301 }
2302 }
2303
2304 // Generic path.
2305 let result = self.dispatch_readonly(input)?;
2306 match result {
2307 QueryResult::Rows { columns, rows } => {
2308 let proj_columns: Vec<String> = fields
2309 .iter()
2310 .map(|f| {
2311 f.alias
2312 .clone()
2313 .unwrap_or_else(|| projection_output_name(&f.expr))
2314 })
2315 .collect();
2316 let mut cancel = crate::cancel::CancelCheck::new();
2317 let mut proj_rows: Vec<Vec<Value>> = Vec::with_capacity(rows.len());
2318 for row in &rows {
2319 cancel.tick()?;
2320 proj_rows.push(
2321 fields
2322 .iter()
2323 .map(|f| eval_expr(&f.expr, row, &columns))
2324 .collect(),
2325 );
2326 }
2327 Ok(QueryResult::Rows {
2328 columns: proj_columns,
2329 rows: proj_rows,
2330 })
2331 }
2332 _ => Err("project requires row input".into()),
2333 }
2334 }
2335
2336 PlanNode::Sort { input, keys } => {
2337 let result = self.dispatch_readonly(input)?;
2338 match result {
2339 QueryResult::Rows { columns, mut rows } => {
2340 if rows.len() > MAX_SORT_ROWS {
2341 return Err(QueryError::SortLimitExceeded);
2342 }
2343 // WS2: byte-budget guard on the sort buffer.
2344 self.charge_rows(&rows)?;
2345 let key_specs: Vec<(Option<usize>, &Expr, bool)> = keys
2346 .iter()
2347 .map(|k| {
2348 let stored_name = match &k.expr {
2349 Expr::Field(name) => Some(name.clone()),
2350 Expr::QualifiedField { qualifier, field } => {
2351 Some(format!("{qualifier}.{field}"))
2352 }
2353 _ => None,
2354 };
2355 // Same resolver the read-write sort path and
2356 // every other clause use, so the readonly path
2357 // does not disagree with them about whether
2358 // `order .amount` inside a join names a column.
2359 let index = stored_name
2360 .as_ref()
2361 .and_then(|name| resolve_column_index(name, &columns));
2362 if let Some(name) = stored_name {
2363 if index.is_none() {
2364 return Err(QueryError::ColumnNotFound {
2365 table: String::new(),
2366 column: name,
2367 });
2368 }
2369 }
2370 Ok((index, &k.expr, k.descending))
2371 })
2372 .collect::<Result<_, QueryError>>()?;
2373 cooperative_stable_sort_by(&mut rows, self.query_memory_limit, |a, b| {
2374 for &(col_idx, expr, descending) in &key_specs {
2375 let (left_value, right_value) = match col_idx {
2376 Some(index) => (&a[index], &b[index]),
2377 None => {
2378 let left = eval_expr(expr, a, &columns);
2379 let right = eval_expr(expr, b, &columns);
2380 let cmp = compare_order_values(&left, &right, descending);
2381 if cmp != std::cmp::Ordering::Equal {
2382 return cmp;
2383 }
2384 continue;
2385 }
2386 };
2387 let cmp = compare_order_values(left_value, right_value, descending);
2388 if cmp != std::cmp::Ordering::Equal {
2389 return cmp;
2390 }
2391 }
2392 std::cmp::Ordering::Equal
2393 })?;
2394 Ok(QueryResult::Rows { columns, rows })
2395 }
2396 _ => Err("sort requires row input".into()),
2397 }
2398 }
2399
2400 PlanNode::Limit { input, count } => {
2401 let result = self.dispatch_readonly(input)?;
2402 let n = match count {
2403 Expr::Literal(Literal::Int(v)) => *v as usize,
2404 _ => return Err("limit must be integer literal".into()),
2405 };
2406 match result {
2407 QueryResult::Rows { columns, rows } => {
2408 let mut cancel = crate::cancel::CancelCheck::new();
2409 let mut limited = Vec::with_capacity(n.min(rows.len()));
2410 for row in rows.into_iter().take(n) {
2411 cancel.tick()?;
2412 limited.push(row);
2413 }
2414 Ok(QueryResult::Rows {
2415 columns,
2416 rows: limited,
2417 })
2418 }
2419 _ => Err("limit requires row input".into()),
2420 }
2421 }
2422
2423 PlanNode::Offset { input, count } => {
2424 let result = self.dispatch_readonly(input)?;
2425 let n = match count {
2426 Expr::Literal(Literal::Int(v)) => *v as usize,
2427 _ => return Err("offset must be integer literal".into()),
2428 };
2429 match result {
2430 QueryResult::Rows { columns, rows } => {
2431 let mut cancel = crate::cancel::CancelCheck::new();
2432 let mut offset = Vec::with_capacity(rows.len().saturating_sub(n));
2433 for (index, row) in rows.into_iter().enumerate() {
2434 cancel.tick()?;
2435 if index >= n {
2436 offset.push(row);
2437 }
2438 }
2439 Ok(QueryResult::Rows {
2440 columns,
2441 rows: offset,
2442 })
2443 }
2444 _ => Err("offset requires row input".into()),
2445 }
2446 }
2447
2448 PlanNode::Aggregate {
2449 input,
2450 function,
2451 argument,
2452 mode: _,
2453 provenance_alias,
2454 } => {
2455 if let Some(provenance_alias) = provenance_alias {
2456 let input = self.materialize_rows_with_provenance(input)?;
2457 self.charge_rows(&input.rows)?;
2458 return aggregate_rows_with_provenance(
2459 *function,
2460 argument.as_ref(),
2461 &input,
2462 provenance_alias,
2463 self.query_memory_limit,
2464 );
2465 }
2466 // Fast path: count() over SeqScan.
2467 // Overflow safety (P0-4): v2-capable tables use the decoded
2468 // generic path (raw count drops >= 64KB rows). A count with a
2469 // target column (`count(T { .v })`) counts non-null values, so
2470 // it must not take this row-counting path.
2471 if *function == AggFunc::Count
2472 && counts_every_row(argument.as_ref())
2473 && !self.generic_path_forced("readonly:count-fast-block")
2474 {
2475 if let PlanNode::SeqScan { table } = input.as_ref() {
2476 if !self.catalog.table_has_overflow(table) {
2477 // A dirty materialized view must be refreshed before
2478 // it can be counted, which needs `&mut self`. Escalate
2479 // to the write path (F3: count(View) returned stale).
2480 if self.view_registry.is_dirty(table) {
2481 return Err(QueryError::ReadonlyNeedsWrite);
2482 }
2483 let mut count: i64 = 0;
2484 for_each_row_raw_cancellable(&self.catalog, table, |_rid, _data| {
2485 count += 1;
2486 })?;
2487 return Ok(QueryResult::Scalar(Value::Int(count)));
2488 }
2489 }
2490 if let PlanNode::Filter {
2491 input: inner,
2492 predicate,
2493 } = input.as_ref()
2494 {
2495 // Only take the fast path for a plain Filter(SeqScan)
2496 // with no subquery in the predicate. A subquery
2497 // predicate (`count(T filter .x in (...))`) must be
2498 // resolved first; the fast path evaluates the raw
2499 // predicate with no subquery materialisation, which
2500 // silently yields 0 (F1). Falling through routes it to
2501 // the generic path that runs the subquery correctly.
2502 if let PlanNode::SeqScan { table } = inner.as_ref() {
2503 if self.view_registry.is_dirty(table) {
2504 // F3: count(View filter ...) over a dirty view.
2505 return Err(QueryError::ReadonlyNeedsWrite);
2506 }
2507 }
2508 if let (PlanNode::SeqScan { table }, false) =
2509 (inner.as_ref(), contains_subquery(predicate))
2510 {
2511 if !self.catalog.table_has_overflow(table) {
2512 let schema = self
2513 .catalog
2514 .schema(table)
2515 .ok_or_else(|| QueryError::TableNotFound(table.clone()))?
2516 .clone();
2517 let columns: Vec<String> =
2518 schema.columns.iter().map(|c| c.name.clone()).collect();
2519 let fast = FastLayout::new(&schema);
2520 let row_layout = RowLayout::new(&schema);
2521
2522 if let Some(compiled) = self.compile_predicate_unless_forced(
2523 "readonly:count-filter:predicate",
2524 predicate,
2525 &columns,
2526 &fast,
2527 &schema,
2528 ) {
2529 let mut count: i64 = 0;
2530 for_each_row_raw_cancellable(
2531 &self.catalog,
2532 table,
2533 |_rid, data| {
2534 if compiled(data) {
2535 count += 1;
2536 }
2537 },
2538 )?;
2539 return Ok(QueryResult::Scalar(Value::Int(count)));
2540 }
2541
2542 let pred_cols = predicate_column_indices_json(predicate, &columns);
2543 let mut count: i64 = 0;
2544 for_each_row_raw_cancellable(
2545 &self.catalog,
2546 table,
2547 |_rid, data| {
2548 let pred_row = decode_selective(
2549 &schema,
2550 &row_layout,
2551 data,
2552 &pred_cols,
2553 );
2554 if eval_predicate(predicate, &pred_row, &columns) {
2555 count += 1;
2556 }
2557 },
2558 )?;
2559 return Ok(QueryResult::Scalar(Value::Int(count)));
2560 }
2561 }
2562 }
2563 }
2564
2565 // Fast path: sum/avg/min/max over single fixed-size numeric.
2566 if matches!(
2567 function,
2568 AggFunc::Sum
2569 | AggFunc::Avg
2570 | AggFunc::Min
2571 | AggFunc::Max
2572 | AggFunc::CountDistinct
2573 ) {
2574 if let Some(Expr::Field(col)) = argument.as_ref() {
2575 let (table_opt, pred_opt): (Option<&str>, Option<&Expr>) =
2576 match input.as_ref() {
2577 PlanNode::SeqScan { table } => (Some(table.as_str()), None),
2578 PlanNode::Filter {
2579 input: inner,
2580 predicate,
2581 } => {
2582 if let PlanNode::SeqScan { table } = inner.as_ref() {
2583 (Some(table.as_str()), Some(predicate))
2584 } else {
2585 (None, None)
2586 }
2587 }
2588 _ => (None, None),
2589 };
2590 if let Some(table) = table_opt {
2591 if let Some(result) =
2592 self.agg_single_col_fast(table, col, *function, pred_opt)?
2593 {
2594 return Ok(result);
2595 }
2596 }
2597 }
2598 }
2599
2600 // Generic path.
2601 let result = self.dispatch_readonly(input)?;
2602 match result {
2603 QueryResult::Rows { columns, rows } => {
2604 aggregate_rows(*function, argument.as_ref(), &columns, &rows)
2605 }
2606 _ => Err("aggregate requires row input".into()),
2607 }
2608 }
2609
2610 PlanNode::Distinct { input } => {
2611 let result = self.dispatch_readonly(input)?;
2612 match result {
2613 QueryResult::Rows { columns, rows } => {
2614 let mut seen = std::collections::HashSet::new();
2615 let mut unique_rows = Vec::new();
2616 let mut cancel = crate::cancel::CancelCheck::new();
2617 for row in rows {
2618 cancel.tick()?;
2619 if seen.insert(row.clone()) {
2620 unique_rows.push(row);
2621 }
2622 }
2623 Ok(QueryResult::Rows {
2624 columns,
2625 rows: unique_rows,
2626 })
2627 }
2628 other => Ok(other),
2629 }
2630 }
2631
2632 PlanNode::GroupBy {
2633 input,
2634 keys,
2635 aggregates,
2636 having,
2637 } => {
2638 if aggregates
2639 .iter()
2640 .any(|aggregate| aggregate.provenance_alias.is_some())
2641 {
2642 let input = self.materialize_rows_with_provenance(input)?;
2643 self.charge_rows(&input.rows)?;
2644 return exec_group_by_with_provenance(
2645 input,
2646 keys,
2647 aggregates,
2648 having,
2649 self.query_memory_limit,
2650 );
2651 }
2652 let result = self.dispatch_readonly(input)?;
2653 match result {
2654 QueryResult::Rows { columns, rows } => {
2655 // WS2: byte-budget guard on the GROUP BY input buffer
2656 // (the hash table is bounded by the input it groups).
2657 self.charge_rows(&rows)?;
2658 exec_group_by(columns, rows, keys, aggregates, having)
2659 }
2660 _ => Err("group by requires row input".into()),
2661 }
2662 }
2663
2664 PlanNode::NestedLoopJoin {
2665 left,
2666 right,
2667 on,
2668 kind,
2669 } => {
2670 let left_result = self.dispatch_readonly(left)?;
2671 let right_result = self.dispatch_readonly(right)?;
2672 let (left_columns, left_rows) = match left_result {
2673 QueryResult::Rows { columns, rows } => (columns, rows),
2674 _ => return Err("join left side must produce rows".into()),
2675 };
2676 let (right_columns, right_rows) = match right_result {
2677 QueryResult::Rows { columns, rows } => (columns, rows),
2678 _ => return Err("join right side must produce rows".into()),
2679 };
2680
2681 // WS2: byte-budget guard on the join build side.
2682 self.charge_rows(&left_rows)?;
2683 self.charge_rows(&right_rows)?;
2684
2685 execute_materialized_join(
2686 left_columns,
2687 left_rows,
2688 right_columns,
2689 right_rows,
2690 on.as_ref(),
2691 *kind,
2692 self.nested_loop_pair_limit,
2693 )
2694 }
2695
2696 PlanNode::Window { input, windows } => {
2697 let result = self.dispatch_readonly(input)?;
2698 execute_window(result, windows, self.query_memory_limit)
2699 }
2700
2701 PlanNode::Union { left, right, all } => {
2702 let left_result = self.dispatch_readonly(left)?;
2703 let right_result = self.dispatch_readonly(right)?;
2704 let (left_cols, left_rows) = match left_result {
2705 QueryResult::Rows { columns, rows } => (columns, rows),
2706 _ => return Err("UNION requires query results on left side".into()),
2707 };
2708 let (_, right_rows) = match right_result {
2709 QueryResult::Rows { columns, rows } => (columns, rows),
2710 _ => return Err("UNION requires query results on right side".into()),
2711 };
2712 Ok(QueryResult::Rows {
2713 columns: left_cols,
2714 rows: union_rows(left_rows, right_rows, *all)?,
2715 })
2716 }
2717
2718 PlanNode::Explain { input } => {
2719 // Every execute entry point runs lower_unindexed_scans before
2720 // dispatch and lowering recurses into Explain, so `input` is
2721 // already the plan that will actually run.
2722 let text = format_plan_tree(&self.catalog, input, 0);
2723 Ok(QueryResult::Rows {
2724 columns: vec!["plan".to_string()],
2725 rows: text
2726 .lines()
2727 .map(|line| vec![Value::Str(line.to_string())])
2728 .collect(),
2729 })
2730 }
2731
2732 PlanNode::ListTypes => self.introspect_list_types(),
2733
2734 PlanNode::Describe { table } => self.introspect_describe(table),
2735
2736 PlanNode::ListLinks => self.introspect_list_links(),
2737
2738 // All write variants — caller must escalate to the write lock.
2739 PlanNode::Insert { .. }
2740 | PlanNode::Update { .. }
2741 | PlanNode::Delete { .. }
2742 | PlanNode::Upsert { .. }
2743 | PlanNode::CreateTable { .. }
2744 | PlanNode::CreateLink { .. }
2745 | PlanNode::DropLink { .. }
2746 | PlanNode::AlterTable { .. }
2747 | PlanNode::DropTable { .. }
2748 | PlanNode::CreateView { .. }
2749 | PlanNode::RefreshView { .. }
2750 | PlanNode::DropView { .. }
2751 | PlanNode::Begin
2752 | PlanNode::Commit
2753 | PlanNode::Rollback => Err(QueryError::ReadonlyNeedsWrite),
2754 }
2755 }
2756
2757 /// `&self` variant of [`Engine::materialize_subqueries`]. Used by the
2758 /// read path so `Filter` predicates with `InSubquery`/`ExistsSubquery`
2759 /// children can evaluate their inner queries without taking the write
2760 /// lock. Inner queries that would themselves need a write (e.g. dirty
2761 /// view) escalate via [`READONLY_NEEDS_WRITE`] just like the top-level
2762 /// read path does.
2763 pub(super) fn plan_output_columns(&self, plan: &PlanNode) -> Option<Vec<String>> {
2764 match plan {
2765 PlanNode::SeqScan { table }
2766 | PlanNode::IndexScan { table, .. }
2767 | PlanNode::RangeScan { table, .. }
2768 | PlanNode::ExprIndexScan { table, .. }
2769 | PlanNode::ExprRangeScan { table, .. }
2770 | PlanNode::OrderedExprIndexScan { table, .. } => self
2771 .catalog
2772 .schema(table)
2773 .map(|s| s.columns.iter().map(|column| column.name.clone()).collect()),
2774 PlanNode::AliasScan { table, alias } => self.catalog.schema(table).map(|s| {
2775 s.columns
2776 .iter()
2777 .map(|column| format!("{alias}.{}", column.name))
2778 .collect()
2779 }),
2780 PlanNode::Filter { input, .. }
2781 | PlanNode::Sort { input, .. }
2782 | PlanNode::Limit { input, .. }
2783 | PlanNode::Offset { input, .. }
2784 | PlanNode::Distinct { input }
2785 | PlanNode::Explain { input } => self.plan_output_columns(input),
2786 PlanNode::Project { fields, .. } => Some(
2787 fields
2788 .iter()
2789 .map(|field| {
2790 field
2791 .alias
2792 .clone()
2793 .unwrap_or_else(|| projection_output_name(&field.expr))
2794 })
2795 .collect(),
2796 ),
2797 PlanNode::NestedProject { fields, .. } => Some(nested_project_output_columns(fields)),
2798 PlanNode::NestedLoopJoin { left, right, .. } => {
2799 let mut columns = self.plan_output_columns(left)?;
2800 columns.extend(self.plan_output_columns(right)?);
2801 Some(columns)
2802 }
2803 PlanNode::Union { left, .. } => self.plan_output_columns(left),
2804 PlanNode::GroupBy {
2805 keys, aggregates, ..
2806 } => {
2807 let mut columns: Vec<String> = keys.iter().map(|key| key.output_name()).collect();
2808 columns.extend(
2809 aggregates
2810 .iter()
2811 .map(|aggregate| aggregate.output_name.clone()),
2812 );
2813 Some(columns)
2814 }
2815 PlanNode::Window { input, windows } => {
2816 let mut columns = self.plan_output_columns(input)?;
2817 columns.extend(windows.iter().map(|window| window.output_name.clone()));
2818 Some(columns)
2819 }
2820 PlanNode::Aggregate { function, .. } => Some(vec![format!("{function:?}")]),
2821 _ => None,
2822 }
2823 }
2824
2825 fn materialize_subqueries_readonly(
2826 &self,
2827 expr: &Expr,
2828 outer_columns: Option<&[String]>,
2829 ) -> Result<Expr, QueryError> {
2830 match expr {
2831 Expr::InSubquery {
2832 expr: inner,
2833 subquery,
2834 negated,
2835 } => {
2836 validate_subquery_scope(subquery, &self.catalog, outer_columns)?;
2837 if is_correlated_subquery(subquery, &self.catalog, outer_columns) {
2838 // Pass through — will be materialized per-row in the
2839 // Filter handler's correlated subquery path.
2840 let inner = self.materialize_subqueries_readonly(inner, outer_columns)?;
2841 return Ok(Expr::InSubquery {
2842 expr: Box::new(inner),
2843 subquery: subquery.clone(),
2844 negated: *negated,
2845 });
2846 }
2847 let inner = self.materialize_subqueries_readonly(inner, outer_columns)?;
2848 let sub_plan = self.plan_and_lower(Statement::Query(*subquery.clone()))?;
2849 let result = self.execute_plan_readonly(&sub_plan)?;
2850 let values = match result {
2851 QueryResult::Rows { rows, .. } => {
2852 let mut values = Vec::with_capacity(rows.len());
2853 let mut cancel = crate::cancel::CancelCheck::new();
2854 for mut row in rows {
2855 cancel.tick()?;
2856 if !row.is_empty() {
2857 values.push(value_to_expr(row.swap_remove(0)));
2858 }
2859 }
2860 values
2861 }
2862 _ => Vec::new(),
2863 };
2864 // WS2: byte-budget guard on the materialized IN-list.
2865 self.charge_in_list(&values)?;
2866 Ok(Expr::InList {
2867 expr: Box::new(inner),
2868 list: values,
2869 negated: *negated,
2870 })
2871 }
2872 Expr::ExistsSubquery { subquery, negated } => {
2873 validate_subquery_scope(subquery, &self.catalog, outer_columns)?;
2874 if is_correlated_subquery(subquery, &self.catalog, outer_columns) {
2875 return Ok(expr.clone());
2876 }
2877 let sub_plan = self.plan_and_lower(Statement::Query(*subquery.clone()))?;
2878 let result = self.execute_plan_readonly(&sub_plan)?;
2879 let has_rows = match result {
2880 QueryResult::Rows { rows, .. } => !rows.is_empty(),
2881 _ => false,
2882 };
2883 let truth = if *negated { !has_rows } else { has_rows };
2884 Ok(Expr::Literal(Literal::Bool(truth)))
2885 }
2886 Expr::BinaryOp(l, op, r) => {
2887 let l = self.materialize_subqueries_readonly(l, outer_columns)?;
2888 let r = self.materialize_subqueries_readonly(r, outer_columns)?;
2889 Ok(Expr::BinaryOp(Box::new(l), *op, Box::new(r)))
2890 }
2891 Expr::UnaryOp(op, inner) => {
2892 let inner = self.materialize_subqueries_readonly(inner, outer_columns)?;
2893 Ok(Expr::UnaryOp(*op, Box::new(inner)))
2894 }
2895 Expr::Case { whens, else_expr } => {
2896 let whens = whens
2897 .iter()
2898 .map(|(c, r)| {
2899 let c = self.materialize_subqueries_readonly(c, outer_columns)?;
2900 let r = self.materialize_subqueries_readonly(r, outer_columns)?;
2901 Ok((Box::new(c), Box::new(r)))
2902 })
2903 .collect::<Result<Vec<_>, QueryError>>()?;
2904 let else_expr = match else_expr {
2905 Some(e) => Some(Box::new(
2906 self.materialize_subqueries_readonly(e, outer_columns)?,
2907 )),
2908 None => None,
2909 };
2910 Ok(Expr::Case { whens, else_expr })
2911 }
2912 other => Ok(other.clone()),
2913 }
2914 }
2915
2916 /// Per-row materialisation of correlated subqueries. For each row in the
2917 /// outer query, substitute outer column references in the subquery's
2918 /// filter with the current row's literal values, execute the modified
2919 /// subquery, and return the result as an InList or Bool literal.
2920 fn materialize_correlated_for_row_readonly(
2921 &self,
2922 expr: &Expr,
2923 outer_row: &[Value],
2924 outer_columns: &[String],
2925 ) -> Result<Expr, QueryError> {
2926 match expr {
2927 Expr::InSubquery {
2928 expr: inner,
2929 subquery,
2930 negated,
2931 } => {
2932 let inner =
2933 self.materialize_correlated_for_row_readonly(inner, outer_row, outer_columns)?;
2934 let mut sub = *subquery.clone();
2935 if let Some(ref filter) = sub.filter {
2936 sub.filter = Some(substitute_outer_refs(
2937 filter,
2938 &sub,
2939 &self.catalog,
2940 outer_row,
2941 outer_columns,
2942 )?);
2943 }
2944 let sub_plan = self.plan_and_lower(Statement::Query(sub))?;
2945 let result = self.execute_plan_readonly(&sub_plan)?;
2946 let values = match result {
2947 QueryResult::Rows { rows, .. } => {
2948 let mut values = Vec::with_capacity(rows.len());
2949 let mut cancel = crate::cancel::CancelCheck::new();
2950 for mut row in rows {
2951 cancel.tick()?;
2952 if !row.is_empty() {
2953 values.push(value_to_expr(row.swap_remove(0)));
2954 }
2955 }
2956 values
2957 }
2958 _ => Vec::new(),
2959 };
2960 // WS2: byte-budget guard on the per-row materialized IN-list.
2961 self.charge_in_list(&values)?;
2962 Ok(Expr::InList {
2963 expr: Box::new(inner),
2964 list: values,
2965 negated: *negated,
2966 })
2967 }
2968 Expr::ExistsSubquery { subquery, negated } => {
2969 let mut sub = *subquery.clone();
2970 if let Some(ref filter) = sub.filter {
2971 sub.filter = Some(substitute_outer_refs(
2972 filter,
2973 &sub,
2974 &self.catalog,
2975 outer_row,
2976 outer_columns,
2977 )?);
2978 }
2979 let sub_plan = self.plan_and_lower(Statement::Query(sub))?;
2980 let result = self.execute_plan_readonly(&sub_plan)?;
2981 let has_rows = match result {
2982 QueryResult::Rows { rows, .. } => !rows.is_empty(),
2983 _ => false,
2984 };
2985 let truth = if *negated { !has_rows } else { has_rows };
2986 Ok(Expr::Literal(Literal::Bool(truth)))
2987 }
2988 Expr::BinaryOp(l, op, r) => {
2989 let l =
2990 self.materialize_correlated_for_row_readonly(l, outer_row, outer_columns)?;
2991 let r =
2992 self.materialize_correlated_for_row_readonly(r, outer_row, outer_columns)?;
2993 Ok(Expr::BinaryOp(Box::new(l), *op, Box::new(r)))
2994 }
2995 Expr::UnaryOp(op, inner) => {
2996 let inner =
2997 self.materialize_correlated_for_row_readonly(inner, outer_row, outer_columns)?;
2998 Ok(Expr::UnaryOp(*op, Box::new(inner)))
2999 }
3000 other => Ok(other.clone()),
3001 }
3002 }
3003
3004 pub fn catalog(&self) -> &Catalog {
3005 &self.catalog
3006 }
3007
3008 pub fn catalog_mut(&mut self) -> &mut Catalog {
3009 &mut self.catalog
3010 }
3011}
3012
3013impl Drop for Engine {
3014 fn drop(&mut self) {
3015 if self.poisoned || self.catalog.is_sync_poisoned() {
3016 self.catalog.abandon_untrusted_state();
3017 return;
3018 }
3019 let Some(hook) = self.wal_archive_hook.clone() else {
3020 return;
3021 };
3022 if let Err(err) = self
3023 .catalog
3024 .checkpoint_with_wal_archive(move |dir, records| hook(dir, records))
3025 {
3026 error!(error = %err, "sync-aware engine checkpoint on drop failed");
3027 }
3028 }
3029}