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