Skip to main content

powdb_query/executor/
mod.rs

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