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