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