spg_engine/lib.rs
1//! SPG execution engine — v0.3 wires the SQL front-end to the in-memory
2//! storage layer. Implements `CREATE TABLE`, single-row `INSERT VALUES`, and
3//! `SELECT * FROM <table>` (no WHERE yet — that lands in v0.4 alongside
4//! expression evaluation against rows).
5#![no_std]
6
7extern crate alloc;
8
9pub mod aggregate;
10mod bytebudget;
11mod cancel;
12mod clock;
13mod constraints;
14mod conversions;
15pub mod copy;
16mod ddl;
17pub mod describe;
18mod dml;
19mod envelope;
20pub mod eval;
21mod execute;
22mod explain;
23mod expr_analysis;
24pub mod fts;
25mod index_access;
26mod join;
27mod joinfold;
28pub mod json;
29mod maintenance;
30pub mod memoize;
31mod numeric;
32mod orderby;
33mod partition;
34pub mod plan_cache;
35mod plpgsql;
36pub mod publications;
37pub mod query_stats;
38mod readonly;
39pub mod reorder;
40pub mod scalarsq_streaming;
41mod select;
42pub mod selectivity;
43mod sequence;
44mod session;
45mod show;
46mod spg_admin;
47pub mod statistics;
48pub mod subquery;
49pub mod subscriptions;
50mod substitute;
51mod system_catalog;
52mod table_access;
53mod transaction;
54pub mod triggers;
55pub mod users;
56mod window;
57
58pub use crate::users::{Role, ScramSecrets, UserError, UserStore};
59pub use cancel::{CancelToken, MonotonicNowFn};
60pub use execute::StreamItem;
61
62use bytebudget::*;
63pub(crate) use clock::{rewrite_clock_calls, value_to_literal};
64use constraints::*;
65use conversions::*;
66pub use conversions::{
67 format_bigint_2d_text_pub, format_bit_string, format_circle, format_hstore_text, format_inet,
68 format_int_2d_text_pub, format_line, format_lseg, format_macaddr, format_macaddr8,
69 format_multirange, format_path, format_pg_box, format_point, format_polygon, format_range_text,
70 format_text_2d_text_pub,
71};
72pub(crate) use ddl::{
73 canonicalize_set_value, enforce_enum_label, eval_runtime_default_free,
74 resolve_column_default_free,
75};
76pub(crate) use envelope::{EnvelopeParse, build_envelope, split_envelope};
77use expr_analysis::*;
78use index_access::*;
79pub use join::{ANTI_JOIN_FAST_PATH_FIRED, ANTI_JOIN_FAST_PATH_TRIED};
80pub(crate) use orderby::{
81 apply_offset_and_limit, apply_offset_and_limit_tagged, build_order_keys, canonical_value_repr,
82 expand_group_by_all, order_by_value_cmp, partial_sort_tagged, render_histogram_bounds,
83 resolve_order_by_position, sort_by_keys, sort_values_for_histogram, value_cmp, value_to_f64,
84};
85pub(crate) use select::{build_projection, infer_column_types, value_to_order_key};
86pub(crate) use show::render_create_table;
87pub use subquery::{
88 BATCHED_SCALAR_FALL_THROUGH_COUNT, BATCHED_SCALAR_KEYED_FIRE_COUNT,
89 BATCHED_SCALAR_KEYED_PROBE_COUNT, EXISTS_BATCH_FALL_THROUGH_COUNT, EXISTS_BATCH_FIRE_COUNT,
90 EXISTS_PULLUP_BAIL_INNER_FROM, EXISTS_PULLUP_BAIL_INNER_SHAPE,
91 EXISTS_PULLUP_BAIL_MULTICOL_DISABLED, EXISTS_PULLUP_BAIL_NO_CORR,
92 EXISTS_PULLUP_BAIL_NO_WHERE, EXISTS_PULLUP_BAIL_RESIDUAL_NOT_INNER,
93 EXISTS_PULLUP_BAIL_UNIQUE_KEY_MISSING, EXISTS_PULLUP_CANDIDATE_COUNT,
94 EXISTS_PULLUP_FIRE_COUNT, EXISTS_PULLUP_MULTICOL_DISABLE, PULLUP_LIMIT1_FIRE_COUNT,
95 SCALARSQ_PK_PROBE_FIRED, ScalarPkProbeFastPath, expr_tree_has_subquery,
96};
97pub(crate) use subquery::{build_in_list_set, collect_scalar_subqueries, expr_has_subquery};
98pub use substitute::substitute_placeholders;
99use substitute::*;
100use system_catalog::*;
101use window::*;
102
103use alloc::collections::BTreeMap;
104use alloc::string::String;
105use alloc::vec::Vec;
106use core::fmt;
107
108// v7.16.0 — re-export the parsed-statement AST so downstream
109// crates (spg-embedded → spg-sqlx) don't need a direct dep on
110// spg-sql for the prepare/bind handle.
111pub use spg_sql::ast::{SelectStatement, Statement as ParsedStatement};
112use spg_sql::parser::ParseError;
113use spg_storage::{Catalog, ColumnSchema, Row, RowChange, StorageError};
114
115use crate::eval::EvalError;
116
117/// Result of executing one statement.
118#[derive(Debug, Clone, PartialEq)]
119#[non_exhaustive]
120pub enum QueryResult {
121 /// DDL or DML succeeded.
122 ///
123 /// `affected` is the row count for `INSERT` and 0 elsewhere.
124 /// `modified_catalog` tells the server whether this statement
125 /// caused the *committed* catalog to change — it's the signal to
126 /// snapshot/audit. False for `BEGIN`/`ROLLBACK`, false for writeful
127 /// statements executed inside a transaction (those only touch the
128 /// shadow), and true for `COMMIT` and for writes outside a TX.
129 CommandOk {
130 affected: usize,
131 modified_catalog: bool,
132 },
133 /// `SELECT` returned a (possibly empty) row set.
134 Rows {
135 columns: Vec<ColumnSchema>,
136 rows: Vec<Row<'static>>,
137 },
138}
139
140/// All errors the engine can return.
141///
142/// Marked `#[non_exhaustive]` from v7.5.0 onward: external `match`
143/// must include a `_` arm so new variants in subsequent v7.x releases
144/// are not breaking changes.
145#[derive(Debug, Clone, PartialEq)]
146#[non_exhaustive]
147pub enum EngineError {
148 Parse(ParseError),
149 Storage(StorageError),
150 Eval(EvalError),
151 /// Front-end accepted a construct that the v0.x executor doesn't support.
152 Unsupported(String),
153 /// `BEGIN` while another transaction is already open.
154 TransactionAlreadyOpen,
155 /// `COMMIT` / `ROLLBACK` with no active transaction.
156 NoActiveTransaction,
157 /// v4.0 sentinel: `execute_readonly` got a statement that
158 /// mutates engine state (INSERT / CREATE / BEGIN / COMMIT / …).
159 /// The caller should retake the write lock and dispatch through
160 /// `execute(&mut self)` instead.
161 WriteRequired,
162 /// v4.2: a SELECT would have returned more rows than the
163 /// configured `max_query_rows` cap. Carries the cap.
164 RowLimitExceeded(usize),
165 /// v7.30.3 (mailrs round-26): a SELECT's join/filter
166 /// materialisation would have held more (approximate) heap
167 /// bytes than the configured `max_query_bytes` cap. The row
168 /// cap above counts rows; this counts bytes, because one row
169 /// can be a multi-MB mail body — 1000 fat rows pressure the
170 /// host long before any row ceiling trips. Carries the cap.
171 QueryBytesExceeded(usize),
172 /// v4.5: cooperative cancellation — the host (server's
173 /// per-query watchdog) set the cancel flag while a long-running
174 /// SELECT / UPDATE / DELETE was scanning rows. The partial work
175 /// is discarded; the caller should surface this as a timeout
176 /// to the client.
177 Cancelled,
178}
179
180impl fmt::Display for EngineError {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 match self {
183 Self::Parse(e) => write!(f, "parse: {e}"),
184 Self::Storage(e) => write!(f, "storage: {e}"),
185 Self::Eval(e) => write!(f, "eval: {e}"),
186 Self::Unsupported(s) => write!(f, "unsupported: {s}"),
187 Self::TransactionAlreadyOpen => f.write_str("a transaction is already open"),
188 Self::NoActiveTransaction => f.write_str("no active transaction"),
189 Self::WriteRequired => {
190 f.write_str("statement requires a write lock (use execute, not execute_readonly)")
191 }
192 Self::RowLimitExceeded(n) => {
193 write!(f, "query exceeded max_query_rows={n}")
194 }
195 Self::QueryBytesExceeded(n) => {
196 write!(
197 f,
198 "query materialisation exceeded max_query_bytes={n} (set SPG_MAX_QUERY_BYTES to raise, 0 to disable)"
199 )
200 }
201 Self::Cancelled => f.write_str("query cancelled (timeout or client request)"),
202 }
203 }
204}
205
206impl From<ParseError> for EngineError {
207 fn from(e: ParseError) -> Self {
208 Self::Parse(e)
209 }
210}
211impl From<StorageError> for EngineError {
212 fn from(e: StorageError) -> Self {
213 Self::Storage(e)
214 }
215}
216impl From<EvalError> for EngineError {
217 fn from(e: EvalError) -> Self {
218 Self::Eval(e)
219 }
220}
221
222/// The execution engine. Holds the catalog and (later) other server-scope
223/// state. `Engine::new()` is intentionally cheap so callers can construct one
224/// per database, per test.
225/// Function pointer that returns "now" as microseconds since Unix
226/// epoch. The engine is `no_std`, so it can't reach for `std::time`
227/// itself — callers (`spg-server`, the sqllogictest runner) inject a
228/// concrete implementation. `None` means `NOW()` / `CURRENT_*` raise
229/// `Unsupported`.
230pub type ClockFn = fn() -> i64;
231
232/// Function pointer that produces 16 cryptographically random bytes.
233/// Like `ClockFn`, the engine is `no_std` and can't reach for /dev/urandom
234/// itself — host (`spg-server`) injects an OS-backed source. `None`
235/// means SQL-driven `CREATE USER` falls back to a deterministic salt
236/// derived from the username (acceptable in tests; the server always
237/// installs a real RNG so production paths never see this).
238pub type SaltFn = fn() -> [u8; 16];
239
240/// v4.5 cooperative cancellation token. A long-running SELECT /
241/// UPDATE / DELETE checks `is_cancelled` at row-loop checkpoints
242/// and bails with `EngineError::Cancelled`. The host
243/// (`spg-server`) creates an `AtomicBool` per query, spawns a
244/// watchdog thread that sets it after `SPG_QUERY_TIMEOUT_MS`,
245/// and passes it via `execute_with_cancel` / `execute_readonly_with_cancel`.
246///
247/// `CancelToken::none()` is a no-op — used by the legacy `execute`
248/// and `execute_readonly` entry points so existing callers don't
249/// change.
250/// v4.41.1 opaque transaction handle. Returned by `Engine::alloc_tx_id`,
251/// threaded through `Engine::execute_in` so dispatch can identify which
252/// in-flight TX a statement belongs to. `IMPLICIT_TX` is the reserved
253/// slot every legacy caller — engine self-tests, spg-cli, spg-embedded,
254/// startup replay — implicitly uses through the unchanged
255/// `Engine::execute(sql)` API. v4.41.1 keeps at most one active slot at
256/// runtime (dispatch holds `engine.write()` across the wrap, same as
257/// v4.34); the map shape is here to let v4.42 turn on N in-flight
258/// implicit TXs without reshuffling the engine internals.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
260pub struct TxId(pub u64);
261
262/// Reserved slot used by `Engine::execute(sql)` — the legacy single-
263/// global-shadow path. New `alloc_tx_id` handles start at 1.
264pub const IMPLICIT_TX: TxId = TxId(0);
265
266/// v6.7.3 — default segment-size threshold used by `COMPACT COLD
267/// SEGMENTS` when no explicit target is supplied. Segments whose
268/// `OwnedSegment::bytes().len()` is **strictly** less than this
269/// value are eligible to merge. spg-server reads
270/// `SPG_COMPACTION_TARGET_SEGMENT_BYTES` to override.
271pub const COMPACTION_TARGET_DEFAULT_BYTES: u64 = 4 * 1024 * 1024;
272
273/// Per-slot transaction state. Held inside `tx_catalogs[tx_id]` for the
274/// lifetime of a BEGIN..COMMIT (or BEGIN..ROLLBACK) window. Drops when
275/// the TX commits (its `catalog` is moved over `Engine.catalog`) or
276/// rolls back (slot removed, catalog discarded).
277#[derive(Debug, Default, Clone)]
278struct TxState {
279 /// The TX's shadow copy of the catalog. Started as a clone of
280 /// `Engine.catalog` at BEGIN time; writes flow into it; COMMIT
281 /// installs it over `Engine.catalog`. `Catalog::clone()` is O(1)
282 /// since v4.40 (`PersistentVec` rows + `PersistentBTreeMap` indices).
283 catalog: Catalog,
284 /// Per-TX savepoint stack. Each entry pairs the savepoint name with
285 /// a clone of `catalog` at the moment `SAVEPOINT <name>` fired.
286 /// `ROLLBACK TO <name>` restores from the entry and pops everything
287 /// after it; `RELEASE <name>` discards the entry and everything
288 /// after; COMMIT/ROLLBACK clears the whole stack.
289 savepoints: Vec<(String, Catalog)>,
290}
291
292/// v7.11.0 — frozen read-only view of the engine's committed state.
293/// Constructed via [`Engine::clone_snapshot`]. Holds clones of the
294/// catalog, statistics, clock function, and row-cap config — the
295/// four fields the `execute_readonly` path actually reads. Cheap to
296/// `Clone` (each clone shares the underlying `PersistentVec` row
297/// storage; only the trie root pointers copy). Send + Sync so a
298/// snapshot can be moved across `tokio::task::spawn_blocking`
299/// boundaries without coordination.
300///
301/// The contract: a snapshot reflects the engine's state at the
302/// moment `clone_snapshot()` returned. Subsequent writes to the
303/// engine are NOT visible. Callers who need fresher data take a
304/// new snapshot.
305#[derive(Debug, Clone)]
306pub struct CatalogSnapshot {
307 catalog: Catalog,
308 statistics: statistics::Statistics,
309 clock: Option<ClockFn>,
310 max_query_rows: Option<usize>,
311}
312
313/// CoW-1 (v7.34) — frozen view of the *persisted* committed engine
314/// state. Carries every field the `snapshot()` envelope serializes;
315/// `Clone` is O(1) on the catalog (Arc bump) and cheap typed-clones
316/// on the trailers. Decouples "capture state" from "serialize bytes"
317/// so the background-checkpoint worker can hold the snapshot and
318/// produce bytes off the engine write lock.
319#[derive(Debug, Clone)]
320pub struct EngineSnapshot {
321 catalog: Catalog,
322 users: UserStore,
323 publications: publications::Publications,
324 subscriptions: subscriptions::Subscriptions,
325 statistics: statistics::Statistics,
326}
327
328impl EngineSnapshot {
329 /// Same envelope rules as `Engine::snapshot()`: bare catalog when
330 /// every trailer is empty, full envelope otherwise.
331 pub fn serialize(&self) -> Vec<u8> {
332 if self.users.is_empty()
333 && self.publications.is_empty()
334 && self.subscriptions.is_empty()
335 && self.statistics.is_empty()
336 {
337 self.catalog.serialize()
338 } else {
339 build_envelope(
340 &self.catalog.serialize(),
341 &users::serialize_users(&self.users),
342 &self.publications.serialize(),
343 &self.subscriptions.serialize(),
344 &self.statistics.serialize(),
345 )
346 }
347 }
348}
349
350// The engine carries several independent session/capture flags (dialect,
351// FK-checks, meta-view materialisation, redo capture); they're orthogonal
352// switches, not a state enum begging to be modelled.
353#[allow(clippy::struct_excessive_bools)]
354#[derive(Debug, Default)]
355pub struct Engine {
356 /// Committed catalog — what survives `Engine::snapshot()` and what
357 /// outside-TX `SELECT`s read.
358 catalog: Catalog,
359 /// Active TX slots, keyed by `TxId`. Empty when no TX is in flight.
360 /// v4.41.1 runtime invariant: at most one entry (single-writer
361 /// model unchanged). v4.42 will let dispatch hold multiple entries
362 /// concurrently for group commit + engine MVCC.
363 tx_catalogs: BTreeMap<TxId, TxState>,
364 /// Which slot the next exec_* call should mutate. Set by
365 /// `execute_in(sql, tx_id)` at the entry point; legacy `execute(sql)`
366 /// sets it to `IMPLICIT_TX`. None when no TX is in flight (read /
367 /// write goes straight against `catalog`).
368 current_tx: Option<TxId>,
369 /// Monotonic counter for `alloc_tx_id`. Starts at 1 — slot 0 is
370 /// reserved for `IMPLICIT_TX`.
371 next_tx_id: u64,
372 /// v7.22 (round-13 T3) — session string-literal dialect. `false`
373 /// (default) = PG semantics (backslash literal, `''` escape);
374 /// `true` = MySQL semantics (`\'` etc.). Flipped by the
375 /// deterministic session signals each dump emits: `SET sql_mode`
376 /// (only MySQL clients/dumps send it) turns it on,
377 /// `SET standard_conforming_strings = on` (every pg_dump
378 /// preamble) turns it off. The plan cache is cleared on every
379 /// flip — the same SQL text lexes differently per dialect.
380 backslash_escapes: bool,
381 /// Optional wall clock used to satisfy `NOW()` / `CURRENT_TIMESTAMP`
382 /// / `CURRENT_DATE`. Set by the host environment.
383 clock: Option<ClockFn>,
384 /// v4.1 cryptographic RNG for per-user password salt. Set by the
385 /// host. `None` means SQL-driven `CREATE USER` uses a
386 /// deterministic fallback — see `SaltFn`.
387 salt_fn: Option<SaltFn>,
388 /// v4.2 per-query row cap. `None` = unlimited. When set, a
389 /// SELECT that materialises more than `n` rows returns
390 /// `EngineError::RowLimitExceeded`. Enforced before the result
391 /// is shaped into wire frames so a runaway scan can't blow the
392 /// server's heap.
393 max_query_rows: Option<usize>,
394 /// v7.30.3 (mailrs round-26) per-query byte cap on join/filter
395 /// materialisation. `None` = unlimited. Approximate net
396 /// accounting (Value heap payloads + per-cell enum overhead)
397 /// charged at every point the join pipeline clones rows;
398 /// crossing the cap raises `EngineError::QueryBytesExceeded`
399 /// instead of pressuring the host into reclaim livelock. The
400 /// host wires this to `SPG_MAX_QUERY_BYTES` (embed defaults it
401 /// ON; the server keeps its allocator-precise budget as the
402 /// outer layer).
403 pub(crate) max_query_bytes: Option<usize>,
404 /// v4.1 RBAC user table. Empty means "no RBAC configured yet" —
405 /// the server decides what that means at the auth boundary
406 /// (open mode vs legacy single-password mode). User CRUD goes
407 /// through `create_user`/`drop_user`/`verify_user`; persistence
408 /// rides the snapshot envelope alongside the catalog.
409 pub(crate) users: UserStore,
410 /// v6.1.2 logical-replication publication catalog. Empty until
411 /// `CREATE PUBLICATION` runs. Persistence rides the v3 envelope
412 /// trailer (see `build_envelope`).
413 publications: publications::Publications,
414 /// v6.1.4 logical-replication subscription catalog. Empty until
415 /// `CREATE SUBSCRIPTION` runs. Persistence rides the v4 envelope
416 /// trailer.
417 subscriptions: subscriptions::Subscriptions,
418 /// v6.2.0 — per-column statistics for the cost-based optimizer.
419 /// Populated by `ANALYZE`; queried via `spg_statistic` virtual
420 /// table. Persistence rides the v5 envelope trailer.
421 statistics: statistics::Statistics,
422 /// v6.3.0 — engine-level plan cache. Caches the post-`prepare()`
423 /// `Statement` keyed on SQL text. In-memory only — does NOT ride
424 /// the snapshot envelope (rebuilt on demand after restart).
425 plan_cache: plan_cache::PlanCache,
426 /// v6.5.1 — per-distinct-SQL execution stats. In-memory only,
427 /// surfaced via `spg_stat_query` virtual table. Updated by the
428 /// `execute_*` paths after a successful execute.
429 query_stats: query_stats::QueryStats,
430 /// v6.5.2 — connection-state provider callback. spg-server
431 /// registers a function at startup that snapshots its
432 /// per-pgwire-connection registry into `ActivityRow`s; engine
433 /// reads through it on every `SELECT * FROM spg_stat_activity`.
434 /// `None` ⇒ no-data (returns empty rows; matches the no_std
435 /// embedded callers that don't run pgwire).
436 activity_provider: Option<ActivityProvider>,
437 /// v6.5.3 — audit-chain provider + verifier. Same pattern as
438 /// activity_provider: spg-server registers both at startup;
439 /// engine reads through on `SELECT * FROM spg_audit_chain` and
440 /// `SELECT * FROM spg_audit_verify`. `None` ⇒ no-data.
441 audit_chain_provider: Option<AuditChainProvider>,
442 audit_verifier: Option<AuditVerifier>,
443 /// v6.5.6 — slow-query log threshold in microseconds. When set,
444 /// every successful execute whose elapsed exceeds the threshold
445 /// gets fed to the registered slow-query log callback (so
446 /// spg-server can emit a structured log line). Default `None`
447 /// = no slow-query logging.
448 slow_query_threshold_us: Option<u64>,
449 slow_query_logger: Option<SlowQueryLogger>,
450 /// v7.12.1 — session parameters set via `SET <name> = <value>`.
451 /// Only `default_text_search_config` is consumed by the engine
452 /// today (the FTS function dispatcher reads it when
453 /// `to_tsvector(text)` is called without an explicit config).
454 /// All other names are accepted + recorded so PG-dump output
455 /// loads, but have no behavioural effect.
456 pub(crate) session_params: BTreeMap<String, String>,
457 /// v7.12.7 — depth counter for trigger-emitted embedded SQL.
458 /// Each time the engine executes a `DeferredEmbeddedStmt` it
459 /// increments this; the recursive `execute_stmt_with_cancel`
460 /// inside that path checks against [`MAX_TRIGGER_RECURSION`]
461 /// to bound runaway cascades (trigger A's UPDATE on table B
462 /// fires trigger B which UPDATEs table A which fires trigger
463 /// A again…). Reset to 0 once the original DML returns.
464 trigger_recursion_depth: u32,
465 /// v7.14.0 — when `SET FOREIGN_KEY_CHECKS=0` is in effect
466 /// (mysqldump preamble), the FK existence + arity check at
467 /// CREATE TABLE time is deferred. FKs referencing a
468 /// not-yet-existing parent land in `pending_foreign_keys`
469 /// keyed by child table; `SET FOREIGN_KEY_CHECKS=1` drains
470 /// the queue and resolves each FK against the now-complete
471 /// catalog. Empty by default; the queue is drained on every
472 /// `RESET ALL` too.
473 foreign_key_checks: bool,
474 /// v7.16.2 — true on the temp Engine an outer
475 /// `exec_select_with_meta_views` builds, telling that
476 /// temp engine "stop short-circuiting into the meta-view
477 /// path — your catalog already has the materialised
478 /// tables; just run the regular SELECT." Without this we'd
479 /// infinite-loop since the meta-view name (e.g.
480 /// `__spg_info_columns`) still triggers
481 /// `select_references_meta_view`.
482 meta_views_materialised: bool,
483 pending_foreign_keys: Vec<(alloc::string::String, spg_sql::ast::ForeignKeyConstraint)>,
484 /// v7.34 (crash-recovery P0 #2) — row-level redo capture. When the
485 /// embedding layer turns this on (persistence enabled), each mutating
486 /// `execute` records the physical [`RowChange`]s it applied; the
487 /// engine drains them into `last_redo` on success, and the embedded
488 /// layer reads them via [`Engine::take_redo`] to write the WAL in
489 /// place of the SQL text. Off (default) = zero capture overhead.
490 redo_capture: bool,
491 /// Redo captured by the most recent successful mutating `execute`,
492 /// awaiting drain by the embedding layer. Cleared on each capture.
493 last_redo: Vec<RowChange>,
494}
495
496/// v7.12.7 — hard cap on nested trigger-emitted embedded SQL
497/// fires. 16 deep is well past anything a normal trigger graph
498/// uses while still preventing infinite-loop wedging.
499const MAX_TRIGGER_RECURSION: u32 = 16;
500
501/// v6.5.6 — callback signature for slow-query log emission. Called
502/// with `(sql, elapsed_us)` once per successful execute that crosses
503/// the threshold.
504pub type SlowQueryLogger = fn(&str, u64);
505
506/// v6.5.2 — one row of `spg_stat_activity`. Engine-public so
507/// spg-server can construct rows without re-exporting internal
508/// dispatch types.
509#[derive(Debug, Clone)]
510pub struct ActivityRow {
511 pub pid: u32,
512 pub user: String,
513 pub started_at_us: i64,
514 pub current_sql: String,
515 pub wait_event: String,
516 pub elapsed_us: i64,
517 pub in_transaction: bool,
518 /// v7.17 Phase 2.4 — startup-param `application_name` (or the
519 /// last value the client sent via `SET application_name = '...'`).
520 /// Empty when the client never declared one.
521 pub application_name: String,
522}
523
524/// v6.5.2 — provider callback type. Fresh snapshot returned each
525/// call; engine doesn't cache the slice.
526pub type ActivityProvider = fn() -> Vec<ActivityRow>;
527
528/// v6.5.3 — one row of `spg_audit_chain`. Engine-public so
529/// spg-server can construct rows directly from `AuditEntry`.
530#[derive(Debug, Clone)]
531pub struct AuditRow {
532 pub seq: i64,
533 pub ts_ms: i64,
534 pub prev_hash_hex: String,
535 pub entry_hash_hex: String,
536 pub sql: String,
537}
538
539/// v6.5.3 — chain-table provider + verifier. spg-server registers
540/// fn pointers that snapshot / verify the audit log. `verify`
541/// returns `(verified_count, broken_at_seq)` — `broken_at_seq` is
542/// `-1` on a clean chain.
543pub type AuditChainProvider = fn() -> Vec<AuditRow>;
544pub type AuditVerifier = fn() -> (i64, i64);
545
546impl Engine {
547 pub fn new() -> Self {
548 Self {
549 catalog: Catalog::new(),
550 tx_catalogs: BTreeMap::new(),
551 current_tx: None,
552 backslash_escapes: false,
553 next_tx_id: 1,
554 clock: None,
555 salt_fn: None,
556 max_query_rows: None,
557 max_query_bytes: None,
558 users: UserStore::new(),
559 publications: publications::Publications::new(),
560 subscriptions: subscriptions::Subscriptions::new(),
561 statistics: statistics::Statistics::new(),
562 plan_cache: plan_cache::PlanCache::new(),
563 query_stats: query_stats::QueryStats::new(),
564 activity_provider: None,
565 audit_chain_provider: None,
566 audit_verifier: None,
567 slow_query_threshold_us: None,
568 slow_query_logger: None,
569 session_params: BTreeMap::new(),
570 trigger_recursion_depth: 0,
571 foreign_key_checks: true,
572 meta_views_materialised: false,
573 pending_foreign_keys: Vec::new(),
574 redo_capture: false,
575 last_redo: Vec::new(),
576 }
577 }
578
579 /// v7.11.0 — clone the engine's committed catalog + read-time
580 /// state into a frozen `CatalogSnapshot`. Cheap (`Catalog` is
581 /// backed by `PersistentVec`; cloning is O(log n) per table).
582 /// Subsequent writes to this engine are invisible to the
583 /// snapshot; the snapshot is self-contained and can be moved
584 /// to another thread for concurrent `execute_readonly_on_snapshot`
585 /// calls. The basis for [`AsyncReadHandle`] in spg-embedded-tokio
586 /// and any other read-fanout pattern.
587 #[must_use]
588 pub fn clone_snapshot(&self) -> CatalogSnapshot {
589 CatalogSnapshot {
590 catalog: self.active_catalog().clone(),
591 statistics: self.statistics.clone(),
592 clock: self.clock,
593 max_query_rows: self.max_query_rows,
594 }
595 }
596
597 /// Construct an engine restored from a previously-snapshotted catalog
598 /// (see `snapshot()`).
599 pub fn restore(catalog: Catalog) -> Self {
600 Self {
601 catalog,
602 tx_catalogs: BTreeMap::new(),
603 current_tx: None,
604 backslash_escapes: false,
605 next_tx_id: 1,
606 clock: None,
607 salt_fn: None,
608 max_query_rows: None,
609 max_query_bytes: None,
610 users: UserStore::new(),
611 publications: publications::Publications::new(),
612 subscriptions: subscriptions::Subscriptions::new(),
613 statistics: statistics::Statistics::new(),
614 plan_cache: plan_cache::PlanCache::new(),
615 query_stats: query_stats::QueryStats::new(),
616 activity_provider: None,
617 audit_chain_provider: None,
618 audit_verifier: None,
619 slow_query_threshold_us: None,
620 slow_query_logger: None,
621 session_params: BTreeMap::new(),
622 trigger_recursion_depth: 0,
623 foreign_key_checks: true,
624 meta_views_materialised: false,
625 pending_foreign_keys: Vec::new(),
626 redo_capture: false,
627 last_redo: Vec::new(),
628 }
629 }
630
631 /// Restore an engine + user table from a v4.1 envelope produced
632 /// by `snapshot_with_users()`. Falls back to plain catalog-only
633 /// restore if the envelope magic isn't present (so v3.x snapshot
634 /// files still load). v6.1.2 adds the optional publications
635 /// trailer (envelope v3); a v1/v2 envelope deserialises to an
636 /// empty publication table.
637 pub fn restore_envelope(buf: &[u8]) -> Result<Self, EngineError> {
638 match split_envelope(buf) {
639 EnvelopeParse::Pair {
640 catalog: catalog_bytes,
641 users: user_bytes,
642 publications: pub_bytes,
643 subscriptions: sub_bytes,
644 statistics: stats_bytes,
645 } => {
646 let catalog = Catalog::deserialize(catalog_bytes).map_err(EngineError::Storage)?;
647 let users = users::deserialize_users(user_bytes)
648 .map_err(|e| EngineError::Unsupported(alloc::format!("users restore: {e}")))?;
649 let publications = match pub_bytes {
650 Some(b) => publications::Publications::deserialize(b).map_err(|e| {
651 EngineError::Unsupported(alloc::format!("publications restore: {e:?}"))
652 })?,
653 None => publications::Publications::new(),
654 };
655 let subscriptions = match sub_bytes {
656 Some(b) => subscriptions::Subscriptions::deserialize(b).map_err(|e| {
657 EngineError::Unsupported(alloc::format!("subscriptions restore: {e:?}"))
658 })?,
659 None => subscriptions::Subscriptions::new(),
660 };
661 let statistics = match stats_bytes {
662 Some(b) => statistics::Statistics::deserialize(b).map_err(|e| {
663 EngineError::Unsupported(alloc::format!("statistics restore: {e:?}"))
664 })?,
665 None => statistics::Statistics::new(),
666 };
667 Ok(Self {
668 catalog,
669 tx_catalogs: BTreeMap::new(),
670 current_tx: None,
671 backslash_escapes: false,
672 next_tx_id: 1,
673 clock: None,
674 salt_fn: None,
675 max_query_rows: None,
676 max_query_bytes: None,
677 users,
678 publications,
679 subscriptions,
680 statistics,
681 plan_cache: plan_cache::PlanCache::new(),
682 query_stats: query_stats::QueryStats::new(),
683 activity_provider: None,
684 audit_chain_provider: None,
685 audit_verifier: None,
686 slow_query_threshold_us: None,
687 slow_query_logger: None,
688 session_params: BTreeMap::new(),
689 trigger_recursion_depth: 0,
690 foreign_key_checks: true,
691 meta_views_materialised: false,
692 pending_foreign_keys: Vec::new(),
693 redo_capture: false,
694 last_redo: Vec::new(),
695 })
696 }
697 EnvelopeParse::CrcMismatch { expected, computed } => {
698 Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
699 "snapshot envelope CRC32 mismatch (expected={expected:#010x}, computed={computed:#010x})"
700 ))))
701 }
702 EnvelopeParse::Bare => {
703 let catalog = Catalog::deserialize(buf).map_err(EngineError::Storage)?;
704 Ok(Self::restore(catalog))
705 }
706 }
707 }
708
709 pub const fn users(&self) -> &UserStore {
710 &self.users
711 }
712
713 /// Builder: attach a wall clock so `NOW()` / `CURRENT_TIMESTAMP` /
714 /// `CURRENT_DATE` evaluate to a real value instead of erroring out.
715 #[must_use]
716 pub const fn with_clock(mut self, clock: ClockFn) -> Self {
717 self.clock = Some(clock);
718 self
719 }
720
721 /// Builder: attach an OS-backed RNG for per-user password salts.
722 /// The host (`spg-server`) typically wires this to `/dev/urandom`.
723 #[must_use]
724 pub const fn with_salt_fn(mut self, f: SaltFn) -> Self {
725 self.salt_fn = Some(f);
726 self
727 }
728
729 /// Builder: cap the number of rows a single SELECT may return.
730 /// Exceeding the cap raises `EngineError::RowLimitExceeded` —
731 /// the bound is checked inside the executor so a runaway
732 /// catalog scan can't allocate millions of rows before the
733 /// server gets a chance to reject the result.
734 #[must_use]
735 pub const fn with_max_query_rows(mut self, n: usize) -> Self {
736 self.max_query_rows = Some(n);
737 self
738 }
739
740 /// Builder: cap the approximate heap bytes a single SELECT's
741 /// join/filter materialisation may hold. Exceeding the cap
742 /// raises `EngineError::QueryBytesExceeded`. Rows are the wrong
743 /// unit when one row carries a multi-MB body (mailrs round-26:
744 /// 1000-row batches of full mail text walked a 15 GiB host into
745 /// reclaim livelock without ever tripping a row ceiling).
746 #[must_use]
747 pub const fn with_max_query_bytes(mut self, n: usize) -> Self {
748 self.max_query_bytes = Some(n);
749 self
750 }
751
752 /// The *committed* catalog. Note: during a transaction this returns the
753 /// pre-TX state — `SELECT` inside a TX goes through `execute()` and reads
754 /// the shadow. Tests that inspect outside-TX state should use this.
755 pub const fn catalog(&self) -> &Catalog {
756 &self.catalog
757 }
758
759 /// Capture a frozen view of the committed engine state. Catalog
760 /// is O(1) Arc bump; trailers are cheap clones. Decouples "capture"
761 /// (needs &Engine) from "serialize" (CPU, no engine access) — the
762 /// seam the background-checkpoint worker rides in CoW-2.
763 pub fn snapshot_data(&self) -> EngineSnapshot {
764 EngineSnapshot {
765 catalog: self.catalog.clone(),
766 users: self.users.clone(),
767 publications: self.publications.clone(),
768 subscriptions: self.subscriptions.clone(),
769 statistics: self.statistics.clone(),
770 }
771 }
772
773 /// Serialize the *committed* catalog to bytes. v0.6 was full-snapshot; v0.9
774 /// adds the rule that an open TX's shadow is never snapshotted — only the
775 /// post-COMMIT state is persisted. v4.1 wraps the catalog in an envelope
776 /// when there are users to persist; an empty user table snapshots as the
777 /// bare catalog format (backwards-compat with v3.x readers). v6.1.2
778 /// adds publications to the envelope condition: either non-empty
779 /// users OR non-empty publications now triggers the envelope path.
780 pub fn snapshot(&self) -> Vec<u8> {
781 self.snapshot_data().serialize()
782 }
783
784 /// True when at least one TX slot is in flight. v4.41.1 runtime
785 /// invariant: at most one slot active at a time (dispatch holds
786 /// `engine.write()` across the entire wrap). v4.42 will let this
787 /// return true with multiple slots concurrently.
788 pub fn in_transaction(&self) -> bool {
789 !self.tx_catalogs.is_empty()
790 }
791
792 /// v4.41.1 allocate a fresh TX handle. Used by spg-server dispatch
793 /// to scope each implicit-wrap BEGIN..stmt..COMMIT to its own slot
794 /// in `tx_catalogs`. v4.42 — the commit-barrier leader allocates
795 /// one of these per task in its group, runs `BEGIN`+sql+`COMMIT`
796 /// sequentially under a single `engine.write()` so each task's
797 /// mutations accumulate into shared state, then either keeps the
798 /// accumulated state (fsync OK) or restores the pre-image via
799 /// `replace_catalog` (fsync err).
800 pub fn alloc_tx_id(&mut self) -> TxId {
801 let id = TxId(self.next_tx_id);
802 self.next_tx_id = self.next_tx_id.saturating_add(1);
803 id
804 }
805
806 /// v4.42 — atomically replace the live catalog. Used by the
807 /// commit-barrier leader to roll back a group whose batched
808 /// fsync failed: the leader snapshots `engine.catalog().clone()`
809 /// (O(1) Arc bump after the v4.39/v4.40 persistent migration)
810 /// at group start, sequentially applies each task's BEGIN+sql+
811 /// COMMIT under the same write lock to accumulate mutations
812 /// into shared state, batches the WAL bytes, fsyncs once, and
813 /// on failure calls this with the pre-image to undo every
814 /// task in the group at once.
815 ///
816 /// **Does NOT touch `tx_catalogs` / `current_tx`.** Any
817 /// explicit-TX slot from a concurrent client (created via the
818 /// legacy `IMPLICIT_TX`-less dispatch path or via the future
819 /// MVCC-readers v5+ work) has its own snapshot baked into the
820 /// slot — restoring `self.catalog` to the pre-image leaves
821 /// those slots untouched, exactly as they were when the leader
822 /// took the lock. The leader's own implicit-TX slots are all
823 /// already discarded (`exec_commit` removed them as each
824 /// task's COMMIT ran) by the time this is reached.
825 pub fn replace_catalog(&mut self, catalog: Catalog) {
826 self.catalog = catalog;
827 }
828
829 /// v6.7.0 — public shim around `Catalog::freeze_oldest_to_cold`
830 /// so tests + the spg-server freezer can drive a freeze without
831 /// reaching into the private `active_catalog_mut`. v6.7.4
832 /// parallel freezer will build on this surface.
833 ///
834 /// Marks the table's cached `cold_row_count` stale because the
835 /// freeze added cold locators that ANALYZE hasn't yet refreshed.
836 pub fn freeze_oldest_to_cold(
837 &mut self,
838 table_name: &str,
839 index_name: &str,
840 max_rows: usize,
841 ) -> Result<spg_storage::FreezeReport, EngineError> {
842 let report = self
843 .active_catalog_mut()
844 .freeze_oldest_to_cold(table_name, index_name, max_rows)
845 .map_err(EngineError::Storage)?;
846 if let Some(t) = self.active_catalog_mut().get_mut(table_name) {
847 t.mark_cold_row_count_stale();
848 }
849 Ok(report)
850 }
851
852 /// v6.7.5 — public shim used by the spg-server follower's
853 /// segment-forwarding receiver. Registers a cold-tier segment
854 /// at a specific id (the master's id, as transmitted on the
855 /// wire) so the follower's BTree-Cold locators stay byte-
856 /// identical with the master's. Wraps
857 /// `Catalog::load_segment_bytes_at` under the standard
858 /// clone-mutate-replace pattern.
859 ///
860 /// Returns `Ok(())` on success **and** on the "slot already
861 /// occupied" case — a follower mid-reconnect may receive a
862 /// segment chunk for a segment_id it already has on disk
863 /// (forwarded last session); the caller should treat that
864 /// path as a no-op rather than a fatal error.
865 pub fn receive_cold_segment(
866 &mut self,
867 segment_id: u32,
868 bytes: Vec<u8>,
869 ) -> Result<(), EngineError> {
870 let mut new_cat = self.catalog.clone();
871 match new_cat.load_segment_bytes_at(segment_id, bytes) {
872 Ok(()) => {
873 self.replace_catalog(new_cat);
874 Ok(())
875 }
876 Err(StorageError::Corrupt(msg)) if msg.contains("already occupied") => Ok(()),
877 Err(e) => Err(EngineError::Storage(e)),
878 }
879 }
880
881 pub(crate) fn active_catalog(&self) -> &Catalog {
882 match self.current_tx {
883 Some(t) => self
884 .tx_catalogs
885 .get(&t)
886 .map_or(&self.catalog, |s| &s.catalog),
887 None => &self.catalog,
888 }
889 }
890
891 fn active_catalog_mut(&mut self) -> &mut Catalog {
892 let tx = self.current_tx;
893 match tx {
894 Some(t) => match self.tx_catalogs.get_mut(&t) {
895 Some(s) => &mut s.catalog,
896 None => &mut self.catalog,
897 },
898 None => &mut self.catalog,
899 }
900 }
901
902 /// v7.34 (crash-recovery P0 #2) — turn row-level redo capture on/off.
903 /// The embedding layer enables it when persistence is on so each
904 /// mutating `execute` records the physical [`RowChange`]s it applied
905 /// (drained via [`Engine::take_redo`]). Off = zero capture overhead.
906 pub fn set_redo_capture(&mut self, on: bool) {
907 self.redo_capture = on;
908 }
909
910 /// v7.37.8 — read accessor for tests / observability. The
911 /// embedding layer flips this on once per `open_path` (after
912 /// replay completes) when `SPG_WAL_ROW_REDO` is enabled (now
913 /// default in v7.37.8). A consumer that wants to verify the
914 /// post-upgrade contract ("writes go to V5 ROW_REDO by default")
915 /// reads this through `Database::engine_redo_capture()` instead
916 /// of inspecting WAL bytes (which the auto-checkpoint truncates
917 /// on `Drop`).
918 pub fn redo_capture_enabled(&self) -> bool {
919 self.redo_capture
920 }
921
922 /// v7.34 — take the redo captured by the most recent successful
923 /// mutating `execute` (empty when capture is off, the statement was a
924 /// read, or it changed nothing). The embedding layer writes these to
925 /// the WAL in place of the SQL text.
926 pub fn take_redo(&mut self) -> Vec<RowChange> {
927 core::mem::take(&mut self.last_redo)
928 }
929
930 /// v7.34 (crash-recovery P0 #2) — replay a row-level redo log onto the
931 /// committed catalog (the row-level WAL recovery primitive: apply the
932 /// captured physical changes from a checkpoint baseline, in place of
933 /// re-executing the SQL). Trusts the log — no uniqueness/FK/parse.
934 pub fn apply_redo(&mut self, changes: &[RowChange]) -> Result<(), EngineError> {
935 self.catalog
936 .apply_redo(changes)
937 .map_err(EngineError::Storage)
938 }
939
940 /// Read-only execute path. Succeeds for `SELECT` / `SHOW TABLES`
941 /// / `SHOW COLUMNS`; returns `EngineError::WriteRequired` for
942 /// every other statement, so the caller can fall through to the
943 /// `&mut self` `execute` path under a write lock. Engine state is
944 /// not mutated even on the success path (`rewrite_clock_calls`
945 /// and `resolve_order_by_position` both mutate the locally-owned
946 /// AST, not `self`).
947 ///
948 /// v4.2: cap result-set size. Applied after the executor
949 /// materialises rows but before they leave the engine — wrapping
950 /// every Rows-returning exec_* function would scatter the check.
951 ///
952 /// v7.31 (memory campaign, bucket A) — the same choke point now
953 /// also enforces the BYTE budget on the final result set, so
954 /// single-table and aggregate paths (which don't route through
955 /// the join materialiser's incremental accounting) still cannot
956 /// hand the host an unbounded result. Intermediate single-table
957 /// clones are the 7.31.x follow-up (design doc, bucket A).
958 fn enforce_row_limit(
959 &self,
960 result: Result<QueryResult, EngineError>,
961 ) -> Result<QueryResult, EngineError> {
962 if let Ok(QueryResult::Rows { rows, .. }) = &result {
963 if let Some(cap) = self.max_query_rows
964 && rows.len() > cap
965 {
966 return Err(EngineError::RowLimitExceeded(cap));
967 }
968 if let Some(byte_cap) = self.max_query_bytes
969 && approx_rows_bytes(rows) > byte_cap
970 {
971 return Err(EngineError::QueryBytesExceeded(byte_cap));
972 }
973 }
974 result
975 }
976}
977
978/// v7.31 (memory campaign — ceiling-first / never-die, design v1) —
979/// per-table slice of the engine's resident-memory accounting.
980/// `hot_encoded_bytes` is the storage layer's maintained meter (what
981/// the rows encode to); `approx_resident_bytes` is what they COST in
982/// RAM (per-cell enum slots + heap payloads via `approx_row_bytes`)
983/// — the gap between the two is the representation multiplier the
984/// round-26 report measured at ~11× end-to-end.
985#[derive(Debug, Clone)]
986pub struct TableMemoryStats {
987 pub name: String,
988 pub hot_rows: u64,
989 /// Cached cold-row count (refreshed by ANALYZE — see
990 /// `Table::cold_row_count`'s staleness contract).
991 pub cold_rows: u64,
992 pub hot_encoded_bytes: u64,
993 pub approx_resident_bytes: u64,
994 pub index_count: u64,
995 /// v7.31 C2 — sum of `IndexKind::approx_resident_bytes()` over the
996 /// table's indices: every variant (BTree / NSW / BRIN / GIN family)
997 /// walks its own structure, so the GIN posting lists and NSW layer
998 /// adjacency that dominate text/vector tables are counted honestly
999 /// instead of the old flat-token estimate.
1000 pub approx_index_bytes: u64,
1001}
1002
1003/// v7.31 — whole-engine memory snapshot: the polling form of the
1004/// round-26 ask-4 watermark signal. Hosts compare
1005/// `total_approx_resident_bytes` (+ their own WAL/file accounting)
1006/// against their deployment ceiling and shed/shrink before the
1007/// kernel does it for them.
1008#[derive(Debug, Clone)]
1009pub struct MemoryStats {
1010 pub tables: Vec<TableMemoryStats>,
1011 pub total_hot_encoded_bytes: u64,
1012 pub total_approx_resident_bytes: u64,
1013 pub total_approx_index_bytes: u64,
1014 /// The active per-query materialisation budget (bucket A), so a
1015 /// monitoring host sees ceiling and usage through one call.
1016 pub max_query_bytes: Option<usize>,
1017 /// v7.31 C2 — bucket D: live WAL bytes (active chunk + buffered,
1018 /// uncheckpointed). `None` from the engine itself — it has no WAL;
1019 /// the durable hosts (embed `Database`, server) fill it in from
1020 /// their own WAL accounting. `Some(0)` means "host has a WAL and
1021 /// it is empty"; `None` means "no WAL on this path" (in-memory).
1022 pub wal_bytes: Option<u64>,
1023}
1024
1025/// v6.2.0 — true for engine-managed catalog tables that the bare
1026/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
1027/// tables yet (publications / subscriptions / users / statistics
1028/// all live as engine fields, not catalog tables), so this is a
1029/// reserved future-proofing hook — every existing user table is
1030/// analysed.
1031const fn is_internal_table_name(_name: &str) -> bool {
1032 false
1033}
1034
1035#[cfg(test)]
1036mod tests;