Skip to main content

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}