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