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