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