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