Skip to main content

powdb_query/executor/
mod.rs

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