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