Skip to main content

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