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