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