Skip to main content

powdb_query/executor/
mod.rs

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