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