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