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
9// v7.37.9 T3 — `bump_counter!(C)` / `bump_counter!(C, N)` macros for the
10// Step VM + aggregate hot-path diagnostic counters. Gated on the
11// `perf-counters` feature so release builds pay zero cost; the
12// `xtests/dogfood_replay/spg-counter-dump` binary turns the feature on
13// to attribute Class A / B / C cascade cost.
14#[cfg(not(feature = "perf-counters"))]
15#[macro_export]
16macro_rules! bump_counter {
17    ($c:path) => {{
18        let _ = &$c;
19    }};
20    ($c:path, $n:expr) => {{
21        let _ = &$c;
22        let _ = &$n;
23    }};
24}
25
26#[cfg(feature = "perf-counters")]
27#[macro_export]
28macro_rules! bump_counter {
29    ($c:path) => {{
30        $c.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
31    }};
32    ($c:path, $n:expr) => {{
33        $c.fetch_add($n, core::sync::atomic::Ordering::Relaxed);
34    }};
35}
36
37mod acl;
38pub mod aggregate;
39pub(crate) mod amcheck;
40mod baregroup;
41pub mod brin;
42mod bytebudget;
43mod cancel;
44mod clock;
45mod collate;
46mod collate_derive;
47mod constfold;
48mod constraints;
49mod conversions;
50pub mod copy;
51mod cursor;
52mod ddl;
53pub mod describe;
54mod distinct;
55mod dml;
56mod dump;
57mod envelope;
58pub mod eval;
59mod execute;
60mod explain;
61mod expr_analysis;
62mod expr_index;
63pub(crate) mod extsort;
64pub mod fts;
65mod guc_catalog;
66mod immutable_fn;
67mod index_access;
68mod join;
69mod join_using;
70mod joinfold;
71pub mod json;
72pub mod largeobject;
73mod limit_expr;
74pub mod locks;
75mod maintenance;
76pub mod memoize;
77mod notify;
78mod numeric;
79mod opclass;
80mod orderby;
81mod partition;
82pub(crate) mod partition_walks;
83pub mod plan_cache;
84mod plpgsql;
85pub mod publications;
86mod qualorder;
87pub mod query_stats;
88mod readonly;
89pub mod reorder;
90mod rls;
91mod rules;
92pub mod scalarsq_streaming;
93mod select;
94pub mod selectivity;
95mod sequence;
96mod session;
97mod show;
98mod spg_admin;
99pub mod statistics;
100pub mod subquery;
101pub mod subscriptions;
102mod substitute;
103mod system_catalog;
104mod table_access;
105pub mod tempstore;
106pub mod testkit;
107mod transaction;
108pub(crate) use transaction::{TxStmtClass, classify_stmt_for_tx};
109pub mod triggers;
110pub mod users;
111mod window;
112
113pub use crate::users::{Role, ScramSecrets, UserError, UserStore};
114pub use cancel::{CancelToken, MonotonicNowFn};
115pub use execute::{RowCells, StreamItem};
116
117use bytebudget::*;
118pub(crate) use clock::{rewrite_clock_calls, value_to_literal};
119use constraints::*;
120pub use constraints::{UNIQ_FOLD_CHOSEN, UNIQ_PROBE_CALLS, UNIQ_PROBE_LOCATORS};
121use conversions::*;
122pub use conversions::{
123    format_bigint_2d_text_pub, format_bit_string, format_circle, format_hstore_text, format_inet,
124    format_int_2d_text_pub, format_line, format_lseg, format_macaddr, format_macaddr8,
125    format_multirange, format_path, format_pg_box, format_pg_lsn, format_point, format_polygon,
126    format_range_text, format_text_2d_text_pub,
127};
128pub(crate) use ddl::{
129    canonicalize_set_value, enforce_enum_label, eval_runtime_default_free,
130    resolve_column_default_free,
131};
132pub(crate) use envelope::{EnvelopeParse, build_envelope, split_envelope};
133use expr_analysis::*;
134use index_access::*;
135pub use join::{ANTI_JOIN_FAST_PATH_FIRED, ANTI_JOIN_FAST_PATH_TRIED};
136pub(crate) use orderby::{
137    OrderKey, apply_offset_and_limit, apply_offset_and_limit_tagged, build_order_keys,
138    canonical_value_repr, cmp_multi_key, expand_group_by_all, order_by_value_cmp,
139    order_by_value_cmp_in, render_histogram_bounds, resolve_order_by_position, sort_by_keys,
140    sort_values_for_histogram, topk_trim, value_cmp, value_to_f64,
141};
142pub use select::{DISTINCT_DUP_DROPPED, PROJ_DIRECT_FIRE, PROJ_ROW_BUILT, SCAN_PATH_ENTERED};
143pub(crate) use select::{build_projection, infer_column_types, value_to_order_key};
144pub use sequence::MUTATING_CALL_NEEDLES;
145pub(crate) use show::render_create_table;
146pub use subquery::{
147    BATCHED_SCALAR_FALL_THROUGH_COUNT, BATCHED_SCALAR_KEYED_FIRE_COUNT,
148    BATCHED_SCALAR_KEYED_PROBE_COUNT, EXISTS_BATCH_FALL_THROUGH_COUNT, EXISTS_BATCH_FIRE_COUNT,
149    EXISTS_PULLUP_BAIL_INNER_FROM, EXISTS_PULLUP_BAIL_INNER_SHAPE,
150    EXISTS_PULLUP_BAIL_MULTICOL_DISABLED, EXISTS_PULLUP_BAIL_NO_CORR, EXISTS_PULLUP_BAIL_NO_WHERE,
151    EXISTS_PULLUP_BAIL_RESIDUAL_NOT_INNER, EXISTS_PULLUP_BAIL_UNIQUE_KEY_MISSING,
152    EXISTS_PULLUP_CANDIDATE_COUNT, EXISTS_PULLUP_FIRE_COUNT, EXISTS_PULLUP_MULTICOL_DISABLE,
153    PULLUP_LIMIT1_FIRE_COUNT, SCALARSQ_PK_PROBE_FIRED, ScalarPkProbeFastPath,
154    expr_tree_has_subquery,
155};
156pub(crate) use subquery::{build_in_list_set, collect_scalar_subqueries, expr_has_subquery};
157pub use substitute::substitute_placeholders;
158use substitute::*;
159use system_catalog::*;
160use window::*;
161
162use alloc::collections::{BTreeMap, BTreeSet};
163use alloc::string::String;
164use alloc::vec::Vec;
165use core::fmt;
166
167// v7.16.0 — re-export the parsed-statement AST so downstream
168// crates (spg-embedded → spg-sqlx) don't need a direct dep on
169// spg-sql for the prepare/bind handle.
170pub use spg_sql::ast::{SelectStatement, Statement as ParsedStatement};
171// v7.37.15 Phase B — re-export the visibility primitives for engine
172// callers so the per-row MVCC types live behind one stable name
173// (`spg_engine::Snapshot` / `spg_engine::RowHeader`) instead of every
174// caller threading through `spg_storage::snapshot::*` directly.
175pub use spg_storage::RowChange;
176pub use spg_storage::row_header::{RowHeader, XMAX_ALIVE, XMIN_FROZEN};
177pub use spg_storage::snapshot::{
178    AllCommitted, InProgressSet, Snapshot, XactStatus, XactStatusOracle,
179};
180
181/// v7.37.15 (Phase C.2) — the engine is its own visibility oracle.
182/// Scans hold `&Engine` while reading, so a scan site can pass `self`
183/// as the [`XactStatusOracle`] alongside its [`Snapshot`] when the
184/// visibility gate migrates from `visible` to `visible_with_status`
185/// (next Phase C step). Delegates to [`Engine::xact_status`].
186impl XactStatusOracle for Engine {
187    fn status(&self, version: u64) -> XactStatus {
188        self.xact_status(version)
189    }
190}
191// v7.37.14 (A2.5-stub) — re-export the silent-FOR-UPDATE telemetry
192// helper through the engine surface so downstream wrappers
193// (spg-embedded / spg-embedded-tokio / spgctl) and their tests
194// don't need a direct `spg-sql` dep.
195use spg_sql::parser::ParseError;
196pub use spg_sql::silent_for_update_count;
197use spg_storage::{Catalog, ColumnSchema, Row, StorageError};
198
199use crate::eval::EvalError;
200
201/// Result of executing one statement.
202#[derive(Debug, Clone, PartialEq)]
203#[non_exhaustive]
204pub enum QueryResult {
205    /// DDL or DML succeeded.
206    ///
207    /// `affected` is the row count for `INSERT` and 0 elsewhere.
208    /// `modified_catalog` tells the server whether this statement
209    /// caused the *committed* catalog to change — it's the signal to
210    /// snapshot/audit. False for `BEGIN`/`ROLLBACK`, false for writeful
211    /// statements executed inside a transaction (those only touch the
212    /// shadow), and true for `COMMIT` and for writes outside a TX.
213    CommandOk {
214        affected: usize,
215        modified_catalog: bool,
216    },
217    /// `SELECT` returned a (possibly empty) row set.
218    Rows {
219        columns: Vec<ColumnSchema>,
220        rows: Vec<Row<'static>>,
221    },
222}
223
224/// All errors the engine can return.
225///
226/// Marked `#[non_exhaustive]` from v7.5.0 onward: external `match`
227/// must include a `_` arm so new variants in subsequent v7.x releases
228/// are not breaking changes.
229#[derive(Debug, Clone, PartialEq)]
230#[non_exhaustive]
231pub enum EngineError {
232    Parse(ParseError),
233    Storage(StorageError),
234    Eval(EvalError),
235    /// Front-end accepted a construct that the v0.x executor doesn't support.
236    Unsupported(String),
237    /// `BEGIN` while another transaction is already open.
238    TransactionAlreadyOpen,
239    /// `COMMIT` / `ROLLBACK` with no active transaction.
240    NoActiveTransaction,
241    /// v7.38 (read01 P3.26) — a statement other than COMMIT / ROLLBACK /
242    /// ROLLBACK TO SAVEPOINT was issued after an earlier statement in the
243    /// same transaction failed. PG aborts the whole transaction on the
244    /// first error and rejects everything until it is ended (SQLSTATE
245    /// 25P02); this mirrors that so partial work can't slip through.
246    InFailedTransaction,
247    /// v7.39 (round 299, E3 Phase 2) — a row lock is held by another
248    /// transaction and the policy is `Wait`.
249    ///
250    /// Its own variant, not an `Unsupported` string: the SERVER has to
251    /// recognise it to retry, and it cannot block inside the engine
252    /// write lock — doing so would stop the whole server, including the
253    /// transaction whose commit would release the lock.
254    LockWouldBlock,
255    /// v7.39 (round 299) — granting the wait would close a wait-for
256    /// cycle. PG's 40P01.
257    LockDeadlock,
258    /// v7.38 (read01 P4.02) — a scalar / row subquery used as an
259    /// expression returned more than one row. PG raises this as
260    /// SQLSTATE 21000 (CARDINALITY_VIOLATION) with a fixed message.
261    CardinalityViolation,
262    /// v7.37.17 (Phase E3) — a REPEATABLE READ / SERIALIZABLE commit
263    /// found a write-write conflict with a concurrently-committed
264    /// transaction (a row this tx wrote was deleted/updated by another
265    /// committed writer, or a unique key this tx inserted was taken).
266    /// PG raises SQLSTATE 40001; the client retries the transaction.
267    /// The failing COMMIT rolls the transaction back, like PG.
268    SerializationFailure(String),
269    /// v4.0 sentinel: `execute_readonly` got a statement that
270    /// mutates engine state (INSERT / CREATE / BEGIN / COMMIT / …).
271    /// The caller should retake the write lock and dispatch through
272    /// `execute(&mut self)` instead.
273    WriteRequired,
274    /// v4.2: a SELECT would have returned more rows than the
275    /// configured `max_query_rows` cap. Carries the cap.
276    RowLimitExceeded(usize),
277    /// v7.30.3 (mailrs round-26): a SELECT's join/filter
278    /// materialisation would have held more (approximate) heap
279    /// bytes than the configured `max_query_bytes` cap. The row
280    /// cap above counts rows; this counts bytes, because one row
281    /// can be a multi-MB mail body — 1000 fat rows pressure the
282    /// host long before any row ceiling trips. Carries the cap.
283    QueryBytesExceeded(usize),
284    /// v4.5: cooperative cancellation — the host (server's
285    /// per-query watchdog) set the cancel flag while a long-running
286    /// SELECT / UPDATE / DELETE was scanning rows. The partial work
287    /// is discarded; the caller should surface this as a timeout
288    /// to the client.
289    Cancelled,
290    /// v7.39 (round 318, V51) — MySQL `KILL <id>` naming an id no live
291    /// connection carries. MariaDB 11: `ERROR 1094 (HY000) Unknown thread
292    /// id: N`.
293    UnknownThreadId(u32),
294    /// v7.39 (round 318, V51) — MySQL `KILL <own id>`. The connection
295    /// really is killed; MariaDB reports it to the victim as
296    /// `ERROR 1927 (70100) Connection was killed` and closes.
297    ConnectionKilled,
298    /// v7.38 Epic P (panic isolation): a panic unwound out of
299    /// statement execution and was caught at the engine's
300    /// `execute_*` boundary (see `execute_in_with_cancel`). The
301    /// in-flight transaction's shadow was discarded (rollback) and
302    /// the engine left consistent; the caller sees this ordinary
303    /// error instead of a crashed process / poisoned lock. NOTE:
304    /// under the release `panic = "abort"` profile the process
305    /// aborts before any unwind, so this variant only ever surfaces
306    /// in dev/test (`panic = "unwind"`) — and in production once a
307    /// later slice flips the release profile to unwind.
308    Internal(String),
309}
310
311impl fmt::Display for EngineError {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        match self {
314            Self::Parse(e) => write!(f, "parse: {e}"),
315            Self::Storage(e) => write!(f, "storage: {e}"),
316            Self::Eval(e) => write!(f, "eval: {e}"),
317            Self::Unsupported(s) => write!(f, "unsupported: {s}"),
318            Self::TransactionAlreadyOpen => f.write_str("a transaction is already open"),
319            Self::NoActiveTransaction => f.write_str("no active transaction"),
320            Self::LockWouldBlock => f.write_str("row is locked by another transaction"),
321            Self::LockDeadlock => f.write_str("deadlock detected"),
322            Self::InFailedTransaction => f.write_str(
323                "current transaction is aborted, commands ignored until end of transaction block",
324            ),
325            Self::CardinalityViolation => {
326                f.write_str("more than one row returned by a subquery used as an expression")
327            }
328            Self::SerializationFailure(detail) => {
329                // v7.39 (round 552) — PG has TWO wordings under 40001 and
330                // they mean different things: "concurrent update" for a
331                // write-write conflict, "read/write dependencies among
332                // transactions" for the antidependency a SERIALIZABLE
333                // transaction hits. A detail that already carries PG's
334                // own sentence is passed through rather than nested
335                // inside the other one.
336                if detail.starts_with("could not serialize access") {
337                    f.write_str(detail)
338                } else {
339                    write!(
340                        f,
341                        "could not serialize access due to concurrent update: {detail}"
342                    )
343                }
344            }
345            Self::WriteRequired => {
346                f.write_str("statement requires a write lock (use execute, not execute_readonly)")
347            }
348            Self::RowLimitExceeded(n) => {
349                write!(f, "query exceeded max_query_rows={n}")
350            }
351            Self::QueryBytesExceeded(n) => {
352                write!(
353                    f,
354                    "query materialisation exceeded max_query_bytes={n} (set SPG_MAX_QUERY_BYTES to raise, 0 to disable)"
355                )
356            }
357            Self::Cancelled => f.write_str("query cancelled (timeout or client request)"),
358            Self::UnknownThreadId(id) => write!(f, "Unknown thread id: {id}"),
359            Self::ConnectionKilled => f.write_str("Connection was killed"),
360            Self::Internal(s) => write!(f, "internal error: {s}"),
361        }
362    }
363}
364
365impl From<ParseError> for EngineError {
366    fn from(e: ParseError) -> Self {
367        Self::Parse(e)
368    }
369}
370impl From<StorageError> for EngineError {
371    fn from(e: StorageError) -> Self {
372        Self::Storage(e)
373    }
374}
375impl From<EvalError> for EngineError {
376    fn from(e: EvalError) -> Self {
377        Self::Eval(e)
378    }
379}
380
381/// The execution engine. Holds the catalog and (later) other server-scope
382/// state. `Engine::new()` is intentionally cheap so callers can construct one
383/// per database, per test.
384/// Function pointer that returns "now" as microseconds since Unix
385/// epoch. The engine is `no_std`, so it can't reach for `std::time`
386/// itself — callers (`spg-server`, the sqllogictest runner) inject a
387/// concrete implementation. `None` means `NOW()` / `CURRENT_*` raise
388/// `Unsupported`.
389pub type ClockFn = fn() -> i64;
390
391/// Backing store for `SPG_TEST_FIXED_CLOCK_MICROS` (test-mode GUC).
392/// Only ever written when that GUC is set; production engines never
393/// touch it.
394static FIXED_CLOCK_MICROS: core::sync::atomic::AtomicI64 = core::sync::atomic::AtomicI64::new(0);
395
396fn fixed_clock_from_env() -> i64 {
397    FIXED_CLOCK_MICROS.load(core::sync::atomic::Ordering::Relaxed)
398}
399
400/// v7.39 (pg_stat knife A) — host-provided live connection count for
401/// `pg_stat_database.numbackends`.
402pub type BackendCountFn = fn() -> u32;
403
404/// v7.39 (read01 pgstatfuncs.c) — host-provided identity of the CALLING
405/// connection for `pg_backend_pid()` / the pg_stat_activity self-join.
406/// The host reads a connection-thread-local set at session start; the
407/// no_std engine just calls through. `None` (embedded) → pid 1.
408pub type BackendPidFn = fn() -> u32;
409
410/// v7.39 (round 476) — the WAL's current byte position, as a PG LSN.
411///
412/// `pg_current_wal_lsn()` answered the literal `0/0` forever, so every
413/// monitor watching WAL progress or replication lag saw an instance that
414/// had never written anything. SPG's WAL is a file and its length IS an
415/// LSN in every sense a monitor uses one: monotonic, byte-denominated, and
416/// comparable — `pg_wal_lsn_diff` over two samples gives real bytes.
417///
418/// `None` (embedded, or a server started without a WAL) keeps `0/0`, which
419/// is the honest answer there: nothing is being written.
420pub type WalLsnFn = fn() -> u64;
421
422/// v7.39 (round 318, V51) — host-provided connection control. `terminate`
423/// false = cancel the target's running statement (PG `pg_cancel_backend`,
424/// MySQL `KILL QUERY`); true = also close the connection (PG
425/// `pg_terminate_backend`, MySQL `KILL CONNECTION`). Returns whether a
426/// connection with that id exists — the engine has no registry of its own,
427/// so the answer has to come from the host that accepted the sockets.
428/// `None` (embedded, no connections) ⇒ nothing to signal.
429pub type BackendSignalFn = fn(pid: u32, terminate: bool) -> bool;
430
431pub use tempstore::{SpillStats, TempRun, TempRunFactory, TempStoreError};
432
433/// v7.39 (tz epic) — host-injected IANA timezone lookups (the no_std
434/// engine can't read the system zoneinfo directory; spg-tzif is the
435/// std-side implementation). All instants are MICROSECONDS.
436/// UTC offset (µs east) of a zone at a UTC instant; None = unknown zone.
437/// v7.39 (round 534) — the compiled-in default PG18 reports for a
438/// configuration parameter, for the wire's own SHOW shortcut.
439///
440/// The pgwire layer answers `SHOW <name>` from a small canned list
441/// before the statement ever reaches the engine, so it needs the same
442/// inventory the engine reads or the two disagree — which they did:
443/// `SHOW fsync` over the wire returned an empty row.
444#[must_use]
445pub fn pg_guc_boot_value(name: &str) -> Option<&'static str> {
446    crate::guc_catalog::guc_boot_value(name)
447}
448
449pub type TzOffsetFn = fn(&str, i64) -> Option<i64>;
450/// Local wall-clock µs -> UTC µs with PG's DST disambiguation.
451pub type TzLocalizeFn = fn(&str, i64) -> Option<i64>;
452/// Canonical zone spelling ("asia/tokyo" -> "Asia/Tokyo").
453pub type TzCanonFn = fn(&str) -> Option<alloc::string::String>;
454/// Zone designation ("JST", "EDT") at a UTC instant.
455pub type TzAbbrevFn = fn(&str, i64) -> Option<alloc::string::String>;
456/// v7.39 (round 502) — every zone the host knows at a UTC instant, as
457/// `(name, abbrev, utc_offset_secs, is_dst)`.
458///
459/// Backs `pg_timezone_names`. SPG resolved named zones correctly — round
460/// 502 measured DST boundaries byte-identical to PG18 — but could not
461/// LIST them, so a client populating a timezone picker got "relation
462/// pg_timezone_names does not exist". The data was there, only
463/// unlistable. No hook, or a host with no tzdata, yields an empty view
464/// rather than an error: that is what such a host honestly has.
465pub type TzAllFn =
466    fn(i64) -> alloc::vec::Vec<(alloc::string::String, alloc::string::String, i64, bool)>;
467
468/// v7.39 (tz epic) — per-statement snapshot of the session TimeZone,
469/// consumed per-VALUE by the timestamptz renderers (a DST zone's
470/// offset depends on the instant being rendered).
471#[derive(Debug, Clone)]
472pub enum SessionTz {
473    Utc,
474    /// Fixed offset, µs east.
475    Fixed(i64),
476    /// IANA zone + the host lookups.
477    Named(alloc::string::String, TzOffsetFn, TzAbbrevFn),
478}
479
480impl SessionTz {
481    #[must_use]
482    pub fn is_utc(&self) -> bool {
483        matches!(self, Self::Utc) || matches!(self, Self::Fixed(0))
484    }
485
486    /// Offset (µs east) at a UTC instant.
487    #[must_use]
488    pub fn offset_at(&self, utc_micros: i64) -> i64 {
489        match self {
490            Self::Utc => 0,
491            Self::Fixed(off) => *off,
492            Self::Named(zone, f, _) => f(zone, utc_micros).unwrap_or(0),
493        }
494    }
495
496    /// Designation for the non-ISO DateStyle suffix: a named zone's
497    /// abbreviation at the instant; None for UTC/fixed (callers spell
498    /// "UTC" / "+09" themselves).
499    #[must_use]
500    pub fn abbrev_at(&self, utc_micros: i64) -> Option<alloc::string::String> {
501        match self {
502            Self::Named(zone, _, f) => f(zone, utc_micros),
503            _ => None,
504        }
505    }
506}
507
508/// Function pointer that produces 16 cryptographically random bytes.
509/// Like `ClockFn`, the engine is `no_std` and can't reach for /dev/urandom
510/// itself — host (`spg-server`) injects an OS-backed source. `None`
511/// means SQL-driven `CREATE USER` falls back to a deterministic salt
512/// derived from the username (acceptable in tests; the server always
513/// installs a real RNG so production paths never see this).
514pub type SaltFn = fn() -> [u8; 16];
515
516/// v4.5 cooperative cancellation token. A long-running SELECT /
517/// UPDATE / DELETE checks `is_cancelled` at row-loop checkpoints
518/// and bails with `EngineError::Cancelled`. The host
519/// (`spg-server`) creates an `AtomicBool` per query, spawns a
520/// watchdog thread that sets it after `SPG_QUERY_TIMEOUT_MS`,
521/// and passes it via `execute_with_cancel` / `execute_readonly_with_cancel`.
522///
523/// `CancelToken::none()` is a no-op — used by the legacy `execute`
524/// and `execute_readonly` entry points so existing callers don't
525/// change.
526/// v4.41.1 opaque transaction handle. Returned by `Engine::alloc_tx_id`,
527/// threaded through `Engine::execute_in` so dispatch can identify which
528/// in-flight TX a statement belongs to. `IMPLICIT_TX` is the reserved
529/// slot every legacy caller — engine self-tests, spg-cli, spg-embedded,
530/// startup replay — implicitly uses through the unchanged
531/// `Engine::execute(sql)` API. v4.41.1 keeps at most one active slot at
532/// runtime (dispatch holds `engine.write()` across the wrap, same as
533/// v4.34); the map shape is here to let v4.42 turn on N in-flight
534/// implicit TXs without reshuffling the engine internals.
535#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
536pub struct TxId(pub u64);
537
538/// Reserved slot used by `Engine::execute(sql)` — the legacy single-
539/// global-shadow path. New `alloc_tx_id` handles start at 1.
540pub const IMPLICIT_TX: TxId = TxId(0);
541
542/// v6.7.3 — default segment-size threshold used by `COMPACT COLD
543/// SEGMENTS` when no explicit target is supplied. Segments whose
544/// `OwnedSegment::bytes().len()` is **strictly** less than this
545/// value are eligible to merge. spg-server reads
546/// `SPG_COMPACTION_TARGET_SEGMENT_BYTES` to override.
547pub const COMPACTION_TARGET_DEFAULT_BYTES: u64 = 4 * 1024 * 1024;
548
549/// Per-slot transaction state. Held inside `tx_catalogs[tx_id]` for the
550/// lifetime of a BEGIN..COMMIT (or BEGIN..ROLLBACK) window. Drops when
551/// the TX commits (its `catalog` is moved over `Engine.catalog`) or
552/// rolls back (slot removed, catalog discarded).
553#[derive(Debug, Default, Clone)]
554struct TxState {
555    /// The TX's shadow copy of the catalog. Started as a clone of
556    /// `Engine.catalog` at BEGIN time; writes flow into it; COMMIT
557    /// installs it over `Engine.catalog`. `Catalog::clone()` is O(1)
558    /// since v4.40 (`PersistentVec` rows + `PersistentBTreeMap` indices).
559    catalog: Catalog,
560    /// v7.37 (round 828) — the TX's shadow copy of the user store,
561    /// following exactly the catalog's model one field up: created
562    /// lazily by the first role DDL inside the TX (an ordinary TX
563    /// never pays the clone), written through for the rest of the TX,
564    /// installed over `Engine.users` at COMMIT, discarded on ROLLBACK.
565    /// PG treats roles as ordinary catalog rows — `BEGIN; CREATE ROLE
566    /// r; ROLLBACK` leaves no role — and SPG used to refuse the
567    /// statement instead, which no drop-in client expects.
568    ///
569    /// Other sessions and the auth path keep reading the committed
570    /// store, so an uncommitted role can neither log in nor be seen
571    /// elsewhere — the isolation PG gives via its catalog MVCC.
572    users: Option<crate::users::UserStore>,
573    /// Per-TX savepoint stack. Each entry pairs the savepoint name with
574    /// a clone of `catalog` (and of the role shadow, which subtransactions
575    /// roll back too) at the moment `SAVEPOINT <name>` fired.
576    /// `ROLLBACK TO <name>` restores from the entry and pops everything
577    /// after it; `RELEASE <name>` discards the entry and everything
578    /// after; COMMIT/ROLLBACK clears the whole stack.
579    savepoints: Vec<(String, Catalog, Option<crate::users::UserStore>)>,
580    /// v7.37.15 (Phase E) — cached MVCC snapshot for REPEATABLE
581    /// READ / SERIALIZABLE. Captured at `exec_begin` time when the
582    /// session's `current_isolation_level` is RR/SER; read paths
583    /// inside the TX use this snapshot rather than calling
584    /// `Engine::current_snapshot` per statement, so a row that
585    /// becomes visible mid-tx (because another writer committed)
586    /// is NOT exposed to this tx — preserving RR's invariant.
587    ///
588    /// `None` for READ COMMITTED (default): each statement gets a
589    /// fresh snapshot via `current_snapshot()`.
590    cached_snapshot: Option<spg_storage::snapshot::Snapshot>,
591    /// v7.37.17 (Phase E2 — RC rebase) — tables this tx has run DML
592    /// against. The per-statement rebase extracts/replays write-sets
593    /// only for these (see `maybe_rc_rebase`).
594    touched_tables: alloc::collections::BTreeSet<String>,
595    /// v7.39 (round 552) — tables this tx has READ. PG's SIREAD locks,
596    /// at table granularity: the coarse end of the same idea, and what
597    /// PG itself falls back to when its per-tuple lock memory runs out.
598    ///
599    /// A SERIALIZABLE tx aborts at COMMIT if any table it read was
600    /// written by a transaction that committed after its snapshot —
601    /// the read/write antidependency SI cannot see. Coarse granularity
602    /// means SPG aborts some transactions PG would let through; it
603    /// never lets through one PG would abort.
604    read_tables: alloc::collections::BTreeSet<String>,
605    /// v7.39 (round 552) — was THIS transaction opened SERIALIZABLE?
606    ///
607    /// `Engine::current_isolation_level` is one field for the whole
608    /// engine, not part of the per-session bag, so with two connections
609    /// open one transaction's COMMIT resets it under the other's feet —
610    /// the shared-engine leak rounds 279 and 283 chased through session
611    /// state and advisory locks. The level a transaction runs at has to
612    /// live on the transaction.
613    serializable: bool,
614    /// The engine's commit sequence when this tx began.
615    begin_commit_seq: u64,
616    /// v7.39 (round 494) — has anything asked for this shadow catalog
617    /// MUTABLY since BEGIN?
618    ///
619    /// COMMIT installs the shadow over the committed catalog, so a
620    /// transaction that changed nothing must install nothing — otherwise
621    /// it reverts whatever other sessions committed while it was open.
622    /// `touched_tables` cannot answer this: it records DML targets for the
623    /// rebase, and a `SELECT lo_write(…)` classifies read-only while
624    /// mutating (the large-object pins caught exactly that).
625    ///
626    /// Set in `active_catalog_mut`, the single place a `&mut Catalog` is
627    /// handed out. A caller that takes the mutable handle without writing
628    /// merely keeps the old install behaviour, so the flag errs toward
629    /// installing.
630    shadow_dirty: bool,
631    /// v7.39 (round 298) — this transaction is in the aborted state.
632    ///
633    /// Per SLOT. It used to be one flag on the shared `Engine`, guarded
634    /// by `in_transaction()` — the GLOBAL "is any transaction open"
635    /// test. So an autocommit statement that failed while a DIFFERENT
636    /// connection happened to hold a transaction set the flag, and
637    /// every other connection was then refused with 25P02. Round 283
638    /// fixed seven sites of this exact shape in the server; this one is
639    /// in the engine and was missed.
640    aborted: bool,
641    /// v7.39 (round 288) — `SET CONSTRAINTS … {DEFERRED|IMMEDIATE}`
642    /// override for this transaction. `None` = each constraint uses
643    /// its own declared timing; `Some(true)` = every DEFERRABLE one is
644    /// deferred; `Some(false)` = every one is immediate.
645    constraints_deferred: Option<bool>,
646    /// v7.39 (round 308) — per-constraint overrides from the NAMED form
647    /// of `SET CONSTRAINTS`. Consulted before `constraints_deferred`, so
648    /// `ALL DEFERRED` followed by `fk_a IMMEDIATE` leaves fk_a immediate
649    /// and everything else deferred. An `ALL` form clears this map,
650    /// which is what makes a later blanket setting win — PG resets the
651    /// whole set the same way.
652    constraints_deferred_by_name: BTreeMap<String, bool>,
653    /// v7.37.17 — the tx executed a statement whose effect on the
654    /// shadow catalog can't be expressed as a versioned row write-set
655    /// (DDL, COPY, anything unclassified). The rebase would lose it,
656    /// so the tx degrades to its frozen BEGIN-time view (SI) for the
657    /// rest of its life — the pre-E2 behaviour, honestly kept.
658    rebase_poisoned: bool,
659    /// v7.37.17 — statements successfully run inside this tx. The
660    /// first statement sees the BEGIN-time clone unchanged (it IS the
661    /// latest base at that point); rebasing starts from the second.
662    stmts_run: u32,
663    /// v7.39 (round 196) — the engine `commit_epoch` this tx last
664    /// rebased against (BEGIN seeds it). When the epoch hasn't moved,
665    /// no other path committed to the base catalog, so the
666    /// per-statement RC rebase — whose write-set extraction is a full
667    /// scan of every touched table — is skipped entirely. The r196
668    /// wire panel traced tx_batch's 2.8× LOSS to exactly that scan
669    /// running before EVERY in-tx statement (~200 µs/stmt on a
670    /// 20k-row table, 58× the statement itself). Over-incrementing
671    /// the epoch is safe (an extra rebase is only slower, never
672    /// wrong); missing an increment would be a correctness bug, so
673    /// the epoch bumps on every completed non-tx statement.
674    rebased_at_epoch: u64,
675    /// v7.37.17 (Phase E4 fix) — (old RowId → new RowId) pairs recorded
676    /// by every in-place UPDATE this tx ran, keyed by table (RowIds are
677    /// per-relation). An UPDATE's write-set is tombstone(old) +
678    /// insert(new); when a rebase skips a CONFLICTING tombstone (the
679    /// row was updated/deleted by a concurrently-committed tx), the
680    /// paired insert must be dropped too — otherwise the row
681    /// DUPLICATES (caught by the E4 isolation matrix).
682    update_pairs: alloc::collections::BTreeMap<
683        String,
684        Vec<(
685            spg_storage::row_header::RowId,
686            spg_storage::row_header::RowId,
687        )>,
688    >,
689}
690
691/// v7.11.0 — frozen read-only view of the engine's committed state.
692/// Constructed via [`Engine::clone_snapshot`]. Holds clones of the
693/// catalog, statistics, clock function, and row-cap config — the
694/// four fields the `execute_readonly` path actually reads. Cheap to
695/// `Clone` (each clone shares the underlying `PersistentVec` row
696/// storage; only the trie root pointers copy). Send + Sync so a
697/// snapshot can be moved across `tokio::task::spawn_blocking`
698/// boundaries without coordination.
699///
700/// The contract: a snapshot reflects the engine's state at the
701/// moment `clone_snapshot()` returned. Subsequent writes to the
702/// engine are NOT visible. Callers who need fresher data take a
703/// new snapshot.
704#[derive(Debug, Clone)]
705pub struct CatalogSnapshot {
706    catalog: Catalog,
707    statistics: statistics::Statistics,
708    clock: Option<ClockFn>,
709    max_query_rows: Option<usize>,
710}
711
712/// CoW-1 (v7.34) — frozen view of the *persisted* committed engine
713/// state. Carries every field the `snapshot()` envelope serializes;
714/// v7.39 (round 279) — the per-CONNECTION state, parked while another
715/// connection holds the engine.
716///
717/// The server runs ONE shared `Engine` behind a `RwLock`
718/// (`ServerState.engine`, built once at startup), so everything the
719/// engine called "session state" was in fact process-wide and leaked
720/// between clients: two connections saw each other's prepared
721/// statements, and one client's `SET sql_mode` re-dialected another's
722/// string literals. PG scopes all of this per session.
723///
724/// Rather than thread a session handle through every call site, the
725/// engine keeps the ACTIVE session's state in its own fields — so the
726/// ~40 existing `self.session_params` / `self.backslash_escapes` uses
727/// are untouched — and swaps the whole bag when the caller announces a
728/// different session. Embedded hosts never announce one and stay on
729/// session 0 forever, exactly as before.
730#[derive(Debug, Default)]
731pub(crate) struct SessionBag {
732    pub(crate) session_params: BTreeMap<String, String>,
733    pub(crate) backslash_escapes: bool,
734    /// v7.39 (round 470) — is the MySQL session in a strict `sql_mode`?
735    ///
736    /// MariaDB's default includes `STRICT_TRANS_TABLES`, so this starts
737    /// true; `SET sql_mode=''` (or any list without a STRICT_ flag) turns
738    /// it off and a value that would otherwise raise is bent to fit
739    /// instead — the same conversion `INSERT IGNORE` uses.
740    pub(crate) mysql_strict: bool,
741    pub(crate) prepared_statements: BTreeMap<String, PreparedSqlStatement>,
742    /// v7.39 (round 499) — the value `nextval` last returned IN THIS
743    /// SESSION, per sequence, and which sequence that was.
744    ///
745    /// PG defines `currval` and `lastval` as session-local: they answer
746    /// the number THIS session was given, and error with 55000 ("not yet
747    /// defined in this session") when it has not called `nextval`. They
748    /// are deliberately not the sequence's current value — another
749    /// session may have advanced it since, and reading that would hand
750    /// back a number this session never owned, which is what a caller
751    /// then uses as a foreign key.
752    ///
753    /// Measured before this (`iso_session` T1/T2): `currval` answered in
754    /// a connection that had never called `nextval`, and `lastval`
755    /// answered across connections, because the tracking lived on the
756    /// shared engine rather than in the bag.
757    pub(crate) seq_currvals: BTreeMap<String, i64>,
758    pub(crate) last_sequence_used: Option<String>,
759    /// v7.39 (round 553) — the isolation level THIS connection is
760    /// running at.
761    ///
762    /// It lived on the shared engine, so it leaked both ways between
763    /// connections. Measured over pgwire against PG18: connection B
764    /// opened a plain BEGIN and `SHOW transaction_isolation` answered
765    /// `serializable` — A's level; and A, still inside its SERIALIZABLE
766    /// block, then read `read committed`, because B's COMMIT reset the
767    /// field under it. PG answers `read committed` and `serializable`
768    /// throughout. So a transaction that asked for SERIALIZABLE ran at
769    /// READ COMMITTED and one that asked for nothing ran at
770    /// SERIALIZABLE, purely because another connection was busy.
771    ///
772    /// Round 552 saw the same field give way and worked around it by
773    /// putting the level on the TRANSACTION; this puts the session's
774    /// own copy where the rest of its state already lives — the place
775    /// r306's comment says every piece of per-connection state belongs
776    /// so it never gets a process-wide version to regress from.
777    pub(crate) isolation_level: spg_sql::ast::IsolationLevel,
778    /// v7.39 (round 306) — open large-object descriptors. Per session
779    /// from the start, deliberately: r277/r279/r283 each landed a piece
780    /// of per-connection state on the process-wide engine first and had
781    /// to be unpicked afterwards, so this one never gets a process-wide
782    /// version to regress from. PG additionally scopes descriptors to
783    /// the transaction, so the table is emptied at COMMIT / ROLLBACK.
784    pub(crate) lo_descriptors: BTreeMap<i32, LargeObjectDescriptor>,
785    /// Next descriptor number to hand out. PG starts at 0 and counts up
786    /// within a transaction, restarting once the transaction ends.
787    pub(crate) lo_next_fd: i32,
788    /// v7.39 (round 321, V54) — open server-side cursors. They lived on
789    /// the shared engine until now, i.e. in ONE namespace for every
790    /// connection: two clients could not both `DECLARE c`, a `FETCH`
791    /// could read another client's rows, and `CLOSE ALL` closed
792    /// everybody's.
793    pub(crate) cursors: BTreeMap<String, cursor::OpenCursor>,
794    /// v7.39 (round 347, M2) — MySQL's `LAST_INSERT_ID()`. Per SESSION
795    /// from the start (r277/r279/r283 each paid for landing per-connection
796    /// state on the shared engine first): one connection's insert must not
797    /// be readable as another's. MariaDB, measured: a fresh session reads
798    /// 0; an insert that generates an AUTO_INCREMENT value sets it to the
799    /// FIRST one generated; a statement that generates none — an explicit
800    /// id, an UPDATE, a DELETE, a plain table — leaves it alone.
801    pub(crate) last_insert_id: i64,
802    /// v7.39 (round 426) — MySQL's `ROW_COUNT()`. Per SESSION like
803    /// `last_insert_id`. MariaDB, measured: a DML statement leaves the
804    /// number of rows it CHANGED (an UPDATE that matched but changed
805    /// nothing leaves 0); a SELECT leaves -1; DDL leaves 0. A FRESH
806    /// session reads 0 (measured), not -1.
807    pub(crate) row_count: i64,
808    /// v7.39 (round 430) — MySQL USER variables (`SET @x = 5`). Per
809    /// SESSION like `last_insert_id` / `row_count`; its own namespace,
810    /// separate from the `@@` session parameters. Reading an unset one
811    /// answers NULL, as MariaDB does.
812    pub(crate) user_vars: BTreeMap<String, spg_storage::Value<'static>>,
813    /// v7.39 (round 436) — the logical names of this session's TEMPORARY
814    /// tables. Each is stored in the catalog under a per-session prefix; this
815    /// set is what says "resolve `t` to my temp one" and what `end_session`
816    /// walks to drop them.
817    pub(crate) temp_tables: alloc::collections::BTreeSet<String>,
818    /// v7.39 (round 469) — the session's TEMPORARY sequences and views, by
819    /// logical name. Separate sets because dropping one at session end has
820    /// to name the catalog map it lives in.
821    pub(crate) temp_sequences: alloc::collections::BTreeSet<String>,
822    pub(crate) temp_views: alloc::collections::BTreeSet<String>,
823}
824
825/// v7.39 (round 306) — one open large-object descriptor.
826#[derive(Debug, Clone, Copy)]
827pub(crate) struct LargeObjectDescriptor {
828    pub(crate) oid: u32,
829    /// Byte offset the next read / write starts at.
830    pub(crate) pos: u64,
831    /// Whether the descriptor was opened with `INV_WRITE`. Reads need no
832    /// permission at all in PG — a write-only descriptor reads fine —
833    /// so only this half is worth remembering.
834    pub(crate) writable: bool,
835}
836
837/// v7.39 (round 277) — one SQL-level prepared statement.
838#[derive(Debug, Clone)]
839pub(crate) struct PreparedSqlStatement {
840    /// The body with its `$N` placeholders still in place.
841    pub(crate) body: spg_sql::ast::Statement,
842    /// Declared parameter type names, in order; empty when PG would
843    /// have inferred them.
844    pub(crate) param_types: alloc::vec::Vec<String>,
845    /// The whole `PREPARE …` text, which `pg_prepared_statements`
846    /// reports verbatim.
847    pub(crate) source: String,
848}
849
850/// `Clone` is O(1) on the catalog (Arc bump) and cheap typed-clones
851/// on the trailers. Decouples "capture state" from "serialize bytes"
852/// so the background-checkpoint worker can hold the snapshot and
853/// produce bytes off the engine write lock.
854#[derive(Debug, Clone)]
855pub struct EngineSnapshot {
856    catalog: Catalog,
857    users: UserStore,
858    publications: publications::Publications,
859    subscriptions: subscriptions::Subscriptions,
860    statistics: statistics::Statistics,
861}
862
863impl EngineSnapshot {
864    /// Same envelope rules as `Engine::snapshot()`: bare catalog when
865    /// every trailer is empty, full envelope otherwise.
866    pub fn serialize(&self) -> Vec<u8> {
867        if self.users.is_empty()
868            && self.publications.is_empty()
869            && self.subscriptions.is_empty()
870            && self.statistics.is_empty()
871        {
872            self.catalog.serialize()
873        } else {
874            build_envelope(
875                &self.catalog.serialize(),
876                &users::serialize_users(&self.users),
877                &self.publications.serialize(),
878                &self.subscriptions.serialize(),
879                &self.statistics.serialize(),
880            )
881        }
882    }
883}
884
885/// v7.39 (parallel-agg P0) — host-injected parallel executor. The
886/// engine is `no_std` and cannot spawn threads; like `ClockFn` /
887/// `RandomFn`, the std-side host (spg-server / embedded-tokio)
888/// injects an implementation at startup. `None` (the default, and
889/// the only option in pure-`no_std` embeddings) keeps every code
890/// path single-threaded and byte-identical to pre-P0 behaviour.
891///
892/// The callback returns `Box<dyn Any + Send>` so one trait serves
893/// any shard-result type; call sites downcast what they produced.
894pub trait ParallelRunner: Send + Sync {
895    /// Run `f(0) .. f(n-1)`, possibly concurrently; return the
896    /// results in shard order. Every call completes before return.
897    fn run_shards(
898        &self,
899        n: usize,
900        f: &(dyn Fn(usize) -> alloc::boxed::Box<dyn core::any::Any + Send> + Sync),
901    ) -> alloc::vec::Vec<alloc::boxed::Box<dyn core::any::Any + Send>>;
902}
903
904/// Engine slot for the injected runner — a newtype so the `Engine`
905/// derive(Debug) keeps working over the non-Debug trait object.
906#[derive(Clone, Default)]
907pub struct ParallelRunnerSlot(pub(crate) Option<alloc::sync::Arc<dyn ParallelRunner>>);
908
909impl core::fmt::Debug for ParallelRunnerSlot {
910    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
911        f.write_str(if self.0.is_some() {
912            "ParallelRunner(<injected>)"
913        } else {
914            "ParallelRunner(none)"
915        })
916    }
917}
918
919/// v7.39 (parallel-agg P0) — below this many input rows a query
920/// never parallelises: thread spin-up beats the win on small scans.
921pub(crate) const PARALLEL_MIN_ROWS: usize = 100_000;
922
923/// v7.39 — diagnostic counter: how many aggregate scans took the
924/// sharded path (read by benches to ground-truth activation).
925/// v7.39 (round 740) — matview delta ground-truth counters (the r735
926/// lesson: a green content pin cannot distinguish "delta applied" from
927/// "silently fell back to full"; these can).
928pub static MATVIEW_FANOUT_BUFFERED: core::sync::atomic::AtomicU64 =
929    core::sync::atomic::AtomicU64::new(0);
930pub static MATVIEW_DELTA_APPLIED: core::sync::atomic::AtomicU64 =
931    core::sync::atomic::AtomicU64::new(0);
932pub static MATVIEW_DELTA_BAILED: core::sync::atomic::AtomicU64 =
933    core::sync::atomic::AtomicU64::new(0);
934pub static PARALLEL_AGG_FIRED: core::sync::atomic::AtomicU64 =
935    core::sync::atomic::AtomicU64::new(0);
936
937// The engine carries several independent session/capture flags (dialect,
938// FK-checks, meta-view materialisation, redo capture); they're orthogonal
939// switches, not a state enum begging to be modelled.
940#[allow(clippy::struct_excessive_bools)]
941#[derive(Debug, Default)]
942pub struct Engine {
943    /// v7.39 (parallel-agg P0) — see [`ParallelRunner`].
944    pub(crate) parallel_runner: ParallelRunnerSlot,
945    /// Committed catalog — what survives `Engine::snapshot()` and what
946    /// outside-TX `SELECT`s read.
947    catalog: Catalog,
948    /// Active TX slots, keyed by `TxId`. Empty when no TX is in flight.
949    /// v4.41.1 runtime invariant: at most one entry (single-writer
950    /// model unchanged). v4.42 will let dispatch hold multiple entries
951    /// concurrently for group commit + engine MVCC.
952    tx_catalogs: BTreeMap<TxId, TxState>,
953    /// v7.39 (round 552) — the COMMIT SEQUENCE at which each table was
954    /// last written. Commit order, not begin order: a transaction that
955    /// began first can commit last, so the writer version allocated at
956    /// BEGIN cannot answer "did this change after I read it".
957    table_last_commit: BTreeMap<String, u64>,
958    /// Monotonic, bumped once per successful COMMIT.
959    commit_seq: u64,
960    /// Which slot the next exec_* call should mutate. Set by
961    /// `execute_in(sql, tx_id)` at the entry point; legacy `execute(sql)`
962    /// sets it to `IMPLICIT_TX`. None when no TX is in flight (read /
963    /// write goes straight against `catalog`).
964    current_tx: Option<TxId>,
965    /// Monotonic counter for `alloc_tx_id`. Starts at 1 — slot 0 is
966    /// reserved for `IMPLICIT_TX`.
967    next_tx_id: u64,
968    /// v7.37.15 (Phase C) — versions allocated by in-flight
969    /// writers. Snapshot construction folds this into the
970    /// `Snapshot.in_progress` set so concurrent readers (or readers
971    /// inside an older snapshot's REPEATABLE READ) don't see
972    /// uncommitted writes.
973    ///
974    /// SPG's single-writer invariant means at most one writer
975    /// version sits here at any moment (the one currently
976    /// executing inside the engine write lock). The set survives
977    /// engine clones because tx-commit removes versions before the
978    /// `Engine::snapshot_data` returns, so a snapshot taken after
979    /// commit observes an empty set.
980    ///
981    /// `BTreeSet` (not Vec) so iteration is sorted — Snapshot
982    /// constructor expects the input sorted for binary-search
983    /// `contains` correctness.
984    active_writer_versions: BTreeSet<u64>,
985    /// v7.37.15 (Phase C.2) — writer versions that ABORTED (rolled
986    /// back). The engine-side visibility oracle ([`Self::xact_status`])
987    /// consults this to report `Aborted` for a version that left the
988    /// in-flight set via rollback rather than commit — the third state
989    /// the abort-aware [`spg_storage::snapshot::Snapshot::visible_with_status`]
990    /// needs once Phase C.3's in-place writes leave aborted stamps in
991    /// place. Pruned below `oldest_active` by vacuum (Phase D); until
992    /// then it grows only with rolled-back transactions (a never-die
993    /// follow-up, not a commit-path leak).
994    aborted_versions: BTreeSet<u64>,
995    /// v7.37.15 (Phase C.4) — row-level lock table keyed on stable
996    /// `(RelId, RowId)`. The in-place write path (C.3) acquires a
997    /// tuple lock before stamping xmax; `exec_commit` / `exec_rollback`
998    /// release the whole transaction's locks at end. No writer acquires
999    /// yet at this commit — the field + delegating methods are the
1000    /// plumbing the write path consumes next. Rides on `Engine` like
1001    /// `active_writer_versions`; the sharded lock-free manager is C.5.
1002    locks: crate::locks::LockTable,
1003    /// v7.37.15 (Phase C.3) — kill switch for the in-place MVCC write
1004    /// path. `false` (default) = legacy physical semantics (DELETE
1005    /// physically removes the row, UPDATE replaces in place). `true` =
1006    /// the C.3 write path (DELETE tombstones via `mark_row_deleted`,
1007    /// UPDATE tombstones the old version + appends the new one, both
1008    /// keeping dead versions physically present for the now-uniformly-
1009    /// gated readers until vacuum reclaims them). `no_std` engine can't
1010    /// read env; the host (spg-server / spg-embedded) reads
1011    /// `SPG_MVCC_INPLACE` and calls [`Self::set_mvcc_inplace`]. Off
1012    /// until the write path + PG18 differential tests are proven.
1013    mvcc_inplace: bool,
1014    /// v7.37.16 — threshold-triggered synchronous vacuum at DML statement
1015    /// exit (autovacuum-lite; see .claude/state/autovacuum-design.md).
1016    /// Default ON; hosts may disable via `SPG_AUTOVACUUM=0`.
1017    autovacuum: bool,
1018    /// v7.39 (round 173) — whether the statement-exit trigger runs the
1019    /// vacuum **inline**. Default ON (embedded: single-threaded host,
1020    /// the statement path is the only place work can happen). A host
1021    /// with a background autovacuum worker (spg-server) flips this off
1022    /// and drives [`Self::autovacuum_tick`] from its own thread instead
1023    /// — PG's shape, where autovacuum never runs inside a client
1024    /// statement. Only meaningful while `autovacuum` itself is on.
1025    autovacuum_inline: bool,
1026    /// v7.37.15 (Phase C) — TxId → writer version registry. When
1027    /// `exec_begin` opens an explicit transaction it allocates a
1028    /// fresh writer version (via [`Self::begin_writer_version`])
1029    /// and stashes the mapping here so the matching `exec_commit`
1030    /// / `exec_rollback` can call
1031    /// [`Self::commit_writer_version`] on the right entry. Empty
1032    /// when no explicit transactions are open.
1033    /// v7.39 (round 295, E3 Phase 1b) — rows a `SKIP LOCKED` pass found
1034    /// held by another transaction. Set by the locking pre-pass and
1035    /// consulted by the base scan, which is READ-only here: the locks
1036    /// themselves were taken under `&mut self` in the pre-pass, so the
1037    /// `&self` scan never mutates the lock table. (A `RefCell` there
1038    /// would cost `Engine: Sync`, which the server's `RwLock<Engine>`
1039    /// needs — see the RFC's §5.6.)
1040    pub(crate) lock_skip_rows: Option<(String, alloc::collections::BTreeSet<usize>)>,
1041    tx_writer_versions: BTreeMap<TxId, u64>,
1042    /// v7.37.15 (Epic W slice 2) — the current statement's autocommit
1043    /// writer version, memoized. In autocommit
1044    /// [`Self::writer_version_for_current_stmt`] mints a fresh version
1045    /// via `next_version()` (a `fetch_add`), so calling it a second
1046    /// time — e.g. when the redo drain post-stamps `RowChange`s —
1047    /// would allocate a *different* number than the writes actually
1048    /// used. Memoizing the first allocation for the duration of one
1049    /// statement makes the drain stamp read back the exact version the
1050    /// rows were written with, without advancing the counter twice.
1051    /// `None` outside a statement / before the first fetch; saved and
1052    /// reset per `execute_in_with_cancel` so it never leaks across
1053    /// statements. Explicit transactions bypass this (their version is
1054    /// the deterministic `tx_writer_versions` entry).
1055    stmt_writer_version: Option<u64>,
1056    /// v7.22 (round-13 T3) — session string-literal dialect. `false`
1057    /// (default) = PG semantics (backslash literal, `''` escape);
1058    /// `true` = MySQL semantics (`\'` etc.). Flipped by the
1059    /// deterministic session signals each dump emits: `SET sql_mode`
1060    /// (only MySQL clients/dumps send it) turns it on,
1061    /// `SET standard_conforming_strings = on` (every pg_dump
1062    /// preamble) turns it off. The plan cache is cleared on every
1063    /// flip — the same SQL text lexes differently per dialect.
1064    backslash_escapes: bool,
1065    /// v7.39 (round 470) — see [`SessionBag::mysql_strict`].
1066    mysql_strict: bool,
1067    /// v7.39 (round 306) — the live session's open large-object
1068    /// descriptors, swapped in and out with the rest of its bag.
1069    pub(crate) lo_descriptors: BTreeMap<i32, LargeObjectDescriptor>,
1070    pub(crate) lo_next_fd: i32,
1071    /// v7.37.17 — name of the sequence most recently advanced by
1072    /// nextval() in this Engine (session). Backs PG's lastval().
1073    /// None until the first nextval; PG errors in that state.
1074    last_sequence_used: Option<String>,
1075    /// v7.39 (round 499) — per-session `currval` values; see
1076    /// [`SessionBag::seq_currvals`].
1077    seq_currvals: alloc::collections::BTreeMap<String, i64>,
1078    /// v7.39 (round 277) — SQL-level prepared statements, session
1079    /// scoped exactly as in PG. Keyed by name; each entry keeps the
1080    /// parsed body (placeholders intact), the declared parameter type
1081    /// names and the statement text `pg_prepared_statements` reports.
1082    prepared_statements: alloc::collections::BTreeMap<String, PreparedSqlStatement>,
1083    /// v7.39 (round 279) — which connection's state is currently
1084    /// installed in the fields above. 0 is the embedded / default
1085    /// session.
1086    current_session: u32,
1087    /// Parked state for every OTHER connection.
1088    sessions: BTreeMap<u32, SessionBag>,
1089    /// v7.39 (round 279) — advisory locks, held ACROSS sessions and so
1090    /// deliberately NOT part of the swapped bag: the whole purpose of
1091    /// an advisory lock is to be visible to the other connection.
1092    /// key → (owning session, re-entrant depth). PG allows the same
1093    /// session to take a lock it already holds.
1094    advisory_locks: BTreeMap<i64, (u32, u32)>,
1095    /// Optional wall clock used to satisfy `NOW()` / `CURRENT_TIMESTAMP`
1096    /// / `CURRENT_DATE`. Set by the host environment.
1097    clock: Option<ClockFn>,
1098    /// v4.1 cryptographic RNG for per-user password salt. Set by the
1099    /// host. `None` means SQL-driven `CREATE USER` uses a
1100    /// deterministic fallback — see `SaltFn`.
1101    salt_fn: Option<SaltFn>,
1102    /// v4.2 per-query row cap. `None` = unlimited. When set, a
1103    /// SELECT that materialises more than `n` rows returns
1104    /// `EngineError::RowLimitExceeded`. Enforced before the result
1105    /// is shaped into wire frames so a runaway scan can't blow the
1106    /// server's heap.
1107    max_query_rows: Option<usize>,
1108    /// v7.30.3 (mailrs round-26) per-query byte cap on join/filter
1109    /// materialisation. `None` = unlimited. Approximate net
1110    /// accounting (Value heap payloads + per-cell enum overhead)
1111    /// charged at every point the join pipeline clones rows;
1112    /// crossing the cap raises `EngineError::QueryBytesExceeded`
1113    /// instead of pressuring the host into reclaim livelock. The
1114    /// host wires this to `SPG_MAX_QUERY_BYTES` (embed defaults it
1115    /// ON; the server keeps its allocator-precise budget as the
1116    /// outer layer).
1117    pub(crate) max_query_bytes: Option<usize>,
1118    /// v7.39 (round 786, T35 Phase A) — host factory for spill runs.
1119    /// `None` (the default, and every embedded caller that has not opted
1120    /// in) keeps today's behaviour exactly: a sort that outgrows
1121    /// `max_query_bytes` still refuses rather than spilling.
1122    pub(crate) temp_run_factory: Option<crate::TempRunFactory>,
1123    /// v4.1 RBAC user table. Empty means "no RBAC configured yet" —
1124    /// the server decides what that means at the auth boundary
1125    /// (open mode vs legacy single-password mode). User CRUD goes
1126    /// through `create_user`/`drop_user`/`verify_user`; persistence
1127    /// rides the snapshot envelope alongside the catalog.
1128    pub(crate) users: UserStore,
1129    /// v6.1.2 logical-replication publication catalog. Empty until
1130    /// `CREATE PUBLICATION` runs. Persistence rides the v3 envelope
1131    /// trailer (see `build_envelope`).
1132    publications: publications::Publications,
1133    /// v6.1.4 logical-replication subscription catalog. Empty until
1134    /// `CREATE SUBSCRIPTION` runs. Persistence rides the v4 envelope
1135    /// trailer.
1136    subscriptions: subscriptions::Subscriptions,
1137    /// v6.2.0 — per-column statistics for the cost-based optimizer.
1138    /// Populated by `ANALYZE`; queried via `spg_statistic` virtual
1139    /// table. Persistence rides the v5 envelope trailer.
1140    statistics: statistics::Statistics,
1141    /// v6.3.0 — engine-level plan cache. Caches the post-`prepare()`
1142    /// `Statement` keyed on SQL text. In-memory only — does NOT ride
1143    /// the snapshot envelope (rebuilt on demand after restart).
1144    plan_cache: plan_cache::PlanCache,
1145    /// v6.5.1 — per-distinct-SQL execution stats. In-memory only,
1146    /// surfaced via `spg_stat_query` virtual table. Updated by the
1147    /// `execute_*` paths after a successful execute.
1148    query_stats: query_stats::QueryStats,
1149    /// v6.5.2 — connection-state provider callback. spg-server
1150    /// registers a function at startup that snapshots its
1151    /// per-pgwire-connection registry into `ActivityRow`s; engine
1152    /// reads through it on every `SELECT * FROM spg_stat_activity`.
1153    /// `None` ⇒ no-data (returns empty rows; matches the no_std
1154    /// embedded callers that don't run pgwire).
1155    activity_provider: Option<ActivityProvider>,
1156    /// v6.5.3 — audit-chain provider + verifier. Same pattern as
1157    /// activity_provider: spg-server registers both at startup;
1158    /// engine reads through on `SELECT * FROM spg_audit_chain` and
1159    /// `SELECT * FROM spg_audit_verify`. `None` ⇒ no-data.
1160    audit_chain_provider: Option<AuditChainProvider>,
1161    audit_verifier: Option<AuditVerifier>,
1162    /// v6.5.6 — slow-query log threshold in microseconds. When set,
1163    /// every successful execute whose elapsed exceeds the threshold
1164    /// gets fed to the registered slow-query log callback (so
1165    /// spg-server can emit a structured log line). Default `None`
1166    /// = no slow-query logging.
1167    slow_query_threshold_us: Option<u64>,
1168    slow_query_logger: Option<SlowQueryLogger>,
1169    /// v7.12.1 — session parameters set via `SET <name> = <value>`.
1170    /// Only `default_text_search_config` is consumed by the engine
1171    /// today (the FTS function dispatcher reads it when
1172    /// `to_tsvector(text)` is called without an explicit config).
1173    /// All other names are accepted + recorded so PG-dump output
1174    /// loads, but have no behavioural effect.
1175    pub(crate) session_params: BTreeMap<String, String>,
1176    /// v7.39 (round 218) — open server-side cursors (DECLARE … CURSOR),
1177    /// keyed by name. Materialized at DECLARE (INSENSITIVE semantics —
1178    /// PG's only actual behaviour too); FETCH / MOVE walk the stored rows.
1179    /// Lifecycle: created only inside a transaction; COMMIT closes
1180    /// non-HOLD cursors and marks WITH HOLD ones held; ROLLBACK closes
1181    /// everything not already held by an earlier commit. Never serialized.
1182    /// Session-scoped in PG; SPG stores them engine-wide (the same
1183    /// process-level session-state architecture wall as `session_params`).
1184    pub(crate) cursors: BTreeMap<String, cursor::OpenCursor>,
1185    /// v7.39 (round 347, M2) — the current session's LAST_INSERT_ID().
1186    /// Swapped with [`SessionBag`] like every other per-connection slot.
1187    /// An atomic because `LAST_INSERT_ID(expr)` SETS it while evaluation
1188    /// holds only `&Engine` — and `Engine` must stay `Sync`, which a
1189    /// `Cell` would have taken away (spg-embedded-tokio shares one across
1190    /// tasks; clippy caught it there before the tests did).
1191    pub(crate) last_insert_id: core::sync::atomic::AtomicI64,
1192    /// v7.39 (round 426) — the current session's ROW_COUNT(). Swapped
1193    /// with [`SessionBag`] like every other per-connection slot. A plain
1194    /// i64: unlike LAST_INSERT_ID it is only ever WRITTEN from the
1195    /// statement driver, which holds `&mut Engine`.
1196    pub(crate) row_count: i64,
1197    /// v7.39 (round 430) — this session's MySQL USER variables.
1198    /// Swapped with [`SessionBag`] like every other per-connection slot.
1199    pub(crate) user_vars: BTreeMap<String, spg_storage::Value<'static>>,
1200    /// v7.39 (round 436) — the logical names of this session's TEMPORARY
1201    /// tables. Swapped with [`SessionBag`]; see `session_temp_name`.
1202    pub(crate) temp_tables: BTreeSet<String>,
1203    pub(crate) temp_sequences: BTreeSet<String>,
1204    pub(crate) temp_views: BTreeSet<String>,
1205    /// v7.39 (round 222) — channels this session LISTENs on. Engine-wide
1206    /// (the same process-level session-state architecture wall as
1207    /// `session_params`). Never serialized.
1208    pub(crate) listen_channels: BTreeSet<String>,
1209    /// v7.39 (round 222) — NOTIFYs raised inside the current transaction,
1210    /// held until COMMIT (PG: transactional delivery, deduplicated within
1211    /// the tx); dropped at ROLLBACK.
1212    pub(crate) tx_pending_notifies: Vec<(String, String)>,
1213    /// v7.39 (round 222) — committed notifications on LISTENed channels,
1214    /// awaiting a drain by the wire layer ('A' NotificationResponse) or an
1215    /// embedded caller ([`Engine::take_notifications`]).
1216    pub(crate) delivered_notifies: Vec<(String, String)>,
1217    /// v7.39 (read01 round 46) — NOTICEs raised by the statement now
1218    /// executing. PG emits a NoticeResponse whenever an `IF EXISTS` /
1219    /// `IF NOT EXISTS` clause makes it skip work ("table \"t\" does not
1220    /// exist, skipping"). The engine appends the PG-worded text here;
1221    /// the caller drains it with [`Engine::take_notices`] after each
1222    /// statement (pgwire turns each into an 'N' message, embedded
1223    /// callers can ignore or surface them). Cleared at the start of
1224    /// every statement so a notice never leaks into the next one.
1225    pending_notices: Vec<Notice>,
1226    /// v7.38 (read01 P3.12) — cumulative row-write counters feeding
1227    /// `pg_stat_database` (database-wide `tup_inserted` / `tup_updated` /
1228    /// `tup_deleted`). Bumped by the affected-row count of each successful
1229    /// INSERT / UPDATE / DELETE statement. Per-Engine (so tests stay
1230    /// isolated); on the server's shared engine they read as the
1231    /// since-start database totals PG reports.
1232    /// v7.39 (pg_stat knife A) — committed / rolled-back transaction
1233    /// counters for pg_stat_database. Atomics so the read-only
1234    /// autocommit path (&self) can count its implicit commit, matching
1235    /// PG (every successful statement outside a tx block is one
1236    /// xact_commit — SELECTs included).
1237    /// v7.37 (round 884) — what sorts have spilled in this process, for
1238    /// `pg_stat_database` and for EXPLAIN ANALYZE's `Sort Method`.
1239    pub(crate) spill_stats: crate::tempstore::SpillStats,
1240    pub(crate) xact_commit: core::sync::atomic::AtomicU64,
1241    pub(crate) xact_rollback: core::sync::atomic::AtomicU64,
1242    /// v7.39 (pg_stat knife A) — host-injected live backend count for
1243    /// pg_stat_database.numbackends (ClockFn-style fn slot; the server
1244    /// wires its connection registry, embedded stays None -> 1).
1245    pub(crate) backend_count_fn: Option<BackendCountFn>,
1246    pub(crate) backend_pid_fn: Option<BackendPidFn>,
1247    /// v7.39 (round 476) — see [`WalLsnFn`].
1248    pub(crate) wal_lsn_fn: Option<WalLsnFn>,
1249    /// v7.39 (round 318, V51) — host connection-control hook. See
1250    /// [`BackendSignalFn`].
1251    pub(crate) backend_signal_fn: Option<BackendSignalFn>,
1252    /// v7.39 (tz epic) — injected IANA timezone lookups; None on a
1253    /// host without zoneinfo (named zones then fail to SET, honestly).
1254    pub(crate) tz_offset_fn: Option<TzOffsetFn>,
1255    pub(crate) tz_localize_fn: Option<TzLocalizeFn>,
1256    pub(crate) tz_canon_fn: Option<TzCanonFn>,
1257    pub(crate) tz_abbrev_fn: Option<TzAbbrevFn>,
1258    /// v7.39 (round 502) — see [`TzAllFn`].
1259    pub(crate) tz_all_fn: Option<TzAllFn>,
1260    pub(crate) stat_tup_inserted: u64,
1261    pub(crate) stat_tup_updated: u64,
1262    pub(crate) stat_tup_deleted: u64,
1263    /// v7.39 (round 192) — per-table DML counters for
1264    /// pg_stat_user_tables (n_tup_ins / n_tup_upd / n_tup_del).
1265    /// Engine-side and NON-transactional, like PG's stats collector:
1266    /// a rolled-back INSERT still counts, and a tx's counts don't
1267    /// ride the shadow catalog (the RC rebase rebuilt shadow tables
1268    /// from the committed base, silently dropping any counter bumped
1269    /// on the shadow — the r192 probe's tx-wrapped inserts read 0).
1270    /// Keyed by table name; DROP TABLE clears, RENAME re-keys.
1271    pub(crate) table_write_stats: alloc::collections::BTreeMap<String, (u64, u64, u64)>,
1272    /// v7.39 (round 196) — bumped after every completed statement that
1273    /// ran OUTSIDE a transaction block (any autocommit statement, plus
1274    /// COMMIT itself via the post-statement check). An open tx whose
1275    /// `rebased_at_epoch` equals this value knows the committed base
1276    /// hasn't moved and skips the per-statement RC rebase (whose
1277    /// write-set extraction full-scans every touched table).
1278    /// Over-approximation is deliberate: read-only statements bump it
1279    /// too, which only costs an extra (correct) rebase.
1280    pub(crate) commit_epoch: u64,
1281    /// v7.38 (read01 P3.19) — `SET LOCAL` undo log for the current
1282    /// transaction. Each entry is `(param_name, prior_value)` captured
1283    /// just before a `SET LOCAL` overwrote it (`None` = the param had no
1284    /// session value, so restoring means removing it). Replayed in
1285    /// reverse at COMMIT / ROLLBACK to revert transaction-local settings;
1286    /// `savepoint_guc_marks` records the stack depth at each open
1287    /// savepoint so `ROLLBACK TO` can unwind just the later ones.
1288    pub(crate) local_guc_saves: Vec<(String, Option<String>)>,
1289    /// v7.39 (GUC knife 3) — parsed DateStyle / IntervalStyle /
1290    /// extra_float_digits, kept in lockstep with `session_params` so
1291    /// renderers don't re-parse GUC text per cell.
1292    pub(crate) render_style: crate::eval::RenderStyle,
1293    pub(crate) savepoint_guc_marks: Vec<(String, usize)>,
1294    /// v7.12.7 — depth counter for trigger-emitted embedded SQL.
1295    /// Each time the engine executes a `DeferredEmbeddedStmt` it
1296    /// increments this; the recursive `execute_stmt_with_cancel`
1297    /// inside that path checks against [`MAX_TRIGGER_RECURSION`]
1298    /// to bound runaway cascades (trigger A's UPDATE on table B
1299    /// fires trigger B which UPDATEs table A which fires trigger
1300    /// A again…). Reset to 0 once the original DML returns.
1301    trigger_recursion_depth: u32,
1302    /// v7.39 (round 140) — set while a DELETE / UPDATE is being re-run by the
1303    /// DO ALSO rule wrapper so the wrapper's inner call does not re-enter the
1304    /// rule-rewrite path (which would recurse forever). INSERT captures its
1305    /// post-image rows directly and needs no such guard.
1306    rule_rewrite_active: bool,
1307    /// v7.14.0 — when `SET FOREIGN_KEY_CHECKS=0` is in effect
1308    /// (mysqldump preamble), the FK existence + arity check at
1309    /// CREATE TABLE time is deferred. FKs referencing a
1310    /// not-yet-existing parent land in `pending_foreign_keys`
1311    /// keyed by child table; `SET FOREIGN_KEY_CHECKS=1` drains
1312    /// the queue and resolves each FK against the now-complete
1313    /// catalog. Empty by default; the queue is drained on every
1314    /// `RESET ALL` too.
1315    foreign_key_checks: bool,
1316    /// v7.16.2 — true on the temp Engine an outer
1317    /// `exec_select_with_meta_views` builds, telling that
1318    /// temp engine "stop short-circuiting into the meta-view
1319    /// path — your catalog already has the materialised
1320    /// tables; just run the regular SELECT." Without this we'd
1321    /// infinite-loop since the meta-view name (e.g.
1322    /// `__spg_info_columns`) still triggers
1323    /// `select_references_meta_view`.
1324    meta_views_materialised: bool,
1325    pending_foreign_keys: Vec<(alloc::string::String, spg_sql::ast::ForeignKeyConstraint)>,
1326    /// v7.38 元机制 D — frozen snapshot of `SPG_TEST_*` env vars. Read
1327    /// once at construction (`with_env_cfg`) and queried on hot paths
1328    /// via `engine.env_cfg().<field>`. Production builds keep this at
1329    /// `EnvConfig::default()`, so the optimiser can const-fold every
1330    /// `if env_cfg.<field>` gate. See `testkit::env_config` + the
1331    /// `xtests/sigil/test-mode-gucs.md` index.
1332    env_cfg: testkit::EnvConfig,
1333    /// v7.38 P0 元机制 A — per-engine `injection_points` attach
1334    /// table. Only exists when the crate is built with the
1335    /// `injection-points` feature; release builds carry no field.
1336    /// Pushed onto the thread-local stack by
1337    /// `enter_injection_scope()` so the `injection_point!()` macro
1338    /// can find it from anywhere in the executor without rewiring
1339    /// every signature. See
1340    /// `crates/spg-engine/src/testkit/injection.rs`.
1341    #[cfg(feature = "injection-points")]
1342    injection_store: alloc::sync::Arc<crate::testkit::injection::InjectionStore>,
1343    /// v7.34 (crash-recovery P0 #2) — row-level redo capture. When the
1344    /// embedding layer turns this on (persistence enabled), each mutating
1345    /// `execute` records the physical [`RowChange`]s it applied; the
1346    /// engine drains them into `last_redo` on success, and the embedded
1347    /// layer reads them via [`Engine::take_redo`] to write the WAL in
1348    /// place of the SQL text. Off (default) = zero capture overhead.
1349    redo_capture: bool,
1350    /// Redo captured by the most recent successful mutating `execute`,
1351    /// awaiting drain by the embedding layer. Cleared on each capture.
1352    last_redo: Vec<RowChange>,
1353    /// v7.39 (round 735, S14/B3) — per-table change sequence, bumped on
1354    /// every write entry (INSERT / UPDATE / DELETE / TRUNCATE / COPY /
1355    /// table-shape DDL). In-memory only: after a restart the map is
1356    /// empty, every watermark comparison misses, and the next REFRESH
1357    /// is a full one — stale-view-safe by construction. A rolled-back
1358    /// transaction's bump stays too, which can only cause an EXTRA full
1359    /// refresh, never a wrong no-op.
1360    table_change_seq: alloc::collections::BTreeMap<String, u64>,
1361    /// v7.39 (round 735, S14/B3) — per-materialized-view refresh
1362    /// watermark: the (table, change-seq) pairs its last full refresh
1363    /// saw. When every dependency's seq is unchanged, REFRESH is an
1364    /// O(1) no-op — an incremental-maintenance first step PG does not
1365    /// have (its REFRESH always recomputes).
1366    matview_refresh_watermark: alloc::collections::BTreeMap<String, Vec<(String, u64)>>,
1367    /// v7.39 (round 736, S14/B3 knife 2) — delta-maintainable
1368    /// materialized views: mv name -> its single base table. Registered
1369    /// at CREATE MATERIALIZED VIEW / full REFRESH when the body is a
1370    /// single-stored-table pure projection (no aggregates / joins /
1371    /// CTEs / subqueries / DISTINCT / ORDER / LIMIT / windows / SRFs).
1372    matview_maintainable: alloc::collections::BTreeMap<String, String>,
1373    /// Buffered base-table row changes per maintainable view, fanned
1374    /// out from the statement redo drain. Capped (see
1375    /// `MATVIEW_DELTA_CEILING`); an overflowed view falls back to a
1376    /// full refresh — never-die, never-stale.
1377    matview_delta_buf: alloc::collections::BTreeMap<String, Vec<RowChange>>,
1378    matview_delta_overflow: alloc::collections::BTreeSet<String>,
1379    /// v7.39 (round 738, S14/B3 knife 3) — per-view row map: expected
1380    /// PHYSICAL length of the view's backing table, plus base-row
1381    /// RowId -> view row position. Built only by the maintainable full
1382    /// refresh's internal scan (the SQL path cannot see rowids), and
1383    /// consulted by the delete/tombstone delta arms. In-memory: restart
1384    /// or any length mismatch (a vacuum moved rows) -> full refresh.
1385    matview_row_map:
1386        alloc::collections::BTreeMap<String, (usize, alloc::collections::BTreeMap<u64, usize>)>,
1387    /// v7.38 轴 4 — currently-selected SQL isolation level. Set by
1388    /// `SET TRANSACTION ISOLATION LEVEL …`; read by
1389    /// `SHOW transaction_isolation`. v7.37.8 implements the
1390    /// SQL surface; actual semantic differentiation (REPEATABLE READ
1391    /// snapshot / SERIALIZABLE SSI) lands in a separate train.
1392    pub(crate) current_isolation_level: spg_sql::ast::IsolationLevel,
1393}
1394
1395/// v7.12.7 — hard cap on nested trigger-emitted embedded SQL
1396/// fires. 16 deep is well past anything a normal trigger graph
1397/// uses while still preventing infinite-loop wedging.
1398const MAX_TRIGGER_RECURSION: u32 = 16;
1399
1400/// v6.5.6 — callback signature for slow-query log emission. Called
1401/// with `(sql, elapsed_us)` once per successful execute that crosses
1402/// the threshold.
1403pub type SlowQueryLogger = fn(&str, u64);
1404
1405/// v6.5.2 — one row of `spg_stat_activity`. Engine-public so
1406/// spg-server can construct rows without re-exporting internal
1407/// dispatch types.
1408#[derive(Debug, Clone)]
1409pub struct ActivityRow {
1410    pub pid: u32,
1411    pub user: String,
1412    /// v7.39 (round 319, V52) — the peer's IP, empty when the connection
1413    /// has no TCP peer (PG reports NULL there).
1414    pub client_addr: String,
1415    /// v7.39 (round 319, V52) — the peer's port. PG reports **-1**, not
1416    /// NULL, for a connection with no TCP port; measured on PG 18.4.
1417    pub client_port: i32,
1418    /// v7.39 (round 319, V52) — the database this connection named. Empty
1419    /// when it named none; both `pg_stat_activity.datname` and
1420    /// `SHOW PROCESSLIST.db` report that as NULL.
1421    pub database: String,
1422    pub started_at_us: i64,
1423    pub current_sql: String,
1424    /// v7.37.14 (B6.3) — PG-style wait-event categorisation
1425    /// ("Lock", "LWLock", "IPC", "IO", "Timeout", "Client",
1426    /// "BufferPin", "Extension", ""). Empty string means idle.
1427    /// Pair with `wait_event` to identify "what specifically is
1428    /// the backend waiting on" the same way PG does.
1429    pub wait_event_type: String,
1430    pub wait_event: String,
1431    pub elapsed_us: i64,
1432    pub in_transaction: bool,
1433    /// v7.17 Phase 2.4 — startup-param `application_name` (or the
1434    /// last value the client sent via `SET application_name = '...'`).
1435    /// Empty when the client never declared one.
1436    pub application_name: String,
1437    /// v7.39 (round 474) — PG's `backend_type`: `client backend` for a
1438    /// connection, or the worker's own name for a background process.
1439    ///
1440    /// pg_stat_activity used to hardcode `client backend`, so SPG's own
1441    /// background workers — the ones that hold the engine write lock and
1442    /// are exactly what an operator is looking for when a statement
1443    /// stalls — did not appear at all. PG18 lists eight of them beside
1444    /// the single client backend on an idle server.
1445    pub backend_type: String,
1446}
1447
1448impl ActivityRow {
1449    /// The `backend_type` PG gives a background process: no database, no
1450    /// user, no query, and a state PG reports as NULL.
1451    #[must_use]
1452    pub fn background(pid: u32, backend_type: &str) -> Self {
1453        Self {
1454            pid,
1455            user: String::new(),
1456            client_addr: String::new(),
1457            client_port: -1,
1458            database: String::new(),
1459            started_at_us: 0,
1460            current_sql: String::new(),
1461            wait_event_type: String::new(),
1462            wait_event: String::new(),
1463            elapsed_us: 0,
1464            in_transaction: false,
1465            application_name: String::new(),
1466            backend_type: backend_type.into(),
1467        }
1468    }
1469}
1470
1471/// v6.5.2 — provider callback type. Fresh snapshot returned each
1472/// call; engine doesn't cache the slice.
1473pub type ActivityProvider = fn() -> Vec<ActivityRow>;
1474
1475/// v7.39 (round 318, V41) — how loud a diagnostic the statement raised is.
1476/// PG distinguishes them on the wire (`S`/`V` fields of NoticeResponse) and
1477/// clients act on it: psql prints `WARNING:` in a different colour, and
1478/// several drivers surface warnings to the application while dropping
1479/// notices. Emitting everything as NOTICE loses that.
1480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1481pub enum NoticeSeverity {
1482    Notice,
1483    Warning,
1484    /// v7.39 (round 757, F31-B3) — `RAISE INFO`. PG sends INFO to the
1485    /// client ALWAYS, regardless of `client_min_messages`.
1486    Info,
1487}
1488
1489impl NoticeSeverity {
1490    /// The non-localized severity string PG puts in the `V` field.
1491    #[must_use]
1492    pub const fn as_pg_str(self) -> &'static str {
1493        match self {
1494            Self::Notice => "NOTICE",
1495            Self::Warning => "WARNING",
1496            Self::Info => "INFO",
1497        }
1498    }
1499}
1500
1501/// v7.39 (round 318, V41) — one diagnostic the statement raised, in PG's
1502/// exact wording minus the severity banner (the wire layer adds that).
1503#[derive(Debug, Clone)]
1504pub struct Notice {
1505    pub severity: NoticeSeverity,
1506    pub message: String,
1507}
1508
1509/// v6.5.3 — one row of `spg_audit_chain`. Engine-public so
1510/// spg-server can construct rows directly from `AuditEntry`.
1511#[derive(Debug, Clone)]
1512pub struct AuditRow {
1513    pub seq: i64,
1514    pub ts_ms: i64,
1515    pub prev_hash_hex: String,
1516    pub entry_hash_hex: String,
1517    pub sql: String,
1518}
1519
1520/// v6.5.3 — chain-table provider + verifier. spg-server registers
1521/// fn pointers that snapshot / verify the audit log. `verify`
1522/// returns `(verified_count, broken_at_seq)` — `broken_at_seq` is
1523/// `-1` on a clean chain.
1524pub type AuditChainProvider = fn() -> Vec<AuditRow>;
1525pub type AuditVerifier = fn() -> (i64, i64);
1526
1527impl Engine {
1528    pub fn new() -> Self {
1529        Self {
1530            catalog: Catalog::new(),
1531            parallel_runner: ParallelRunnerSlot::default(),
1532            tx_catalogs: BTreeMap::new(),
1533            table_last_commit: BTreeMap::new(),
1534            commit_seq: 0,
1535            current_tx: None,
1536            backslash_escapes: false,
1537            mysql_strict: true,
1538            lo_descriptors: BTreeMap::new(),
1539            lo_next_fd: 0,
1540            prepared_statements: alloc::collections::BTreeMap::new(),
1541            current_session: 0,
1542            sessions: BTreeMap::new(),
1543            advisory_locks: BTreeMap::new(),
1544            last_sequence_used: None,
1545            seq_currvals: alloc::collections::BTreeMap::new(),
1546            next_tx_id: 1,
1547            active_writer_versions: BTreeSet::new(),
1548            aborted_versions: BTreeSet::new(),
1549            locks: crate::locks::LockTable::new(),
1550            mvcc_inplace: !cfg!(feature = "mvcc-inplace-off"),
1551            autovacuum: true,
1552            autovacuum_inline: true,
1553            lock_skip_rows: None,
1554            tx_writer_versions: BTreeMap::new(),
1555            stmt_writer_version: None,
1556            clock: None,
1557            salt_fn: None,
1558            max_query_rows: None,
1559            max_query_bytes: None,
1560            temp_run_factory: None,
1561            users: UserStore::new(),
1562            publications: publications::Publications::new(),
1563            subscriptions: subscriptions::Subscriptions::new(),
1564            statistics: statistics::Statistics::new(),
1565            plan_cache: plan_cache::PlanCache::new(),
1566            query_stats: query_stats::QueryStats::new(),
1567            activity_provider: None,
1568            audit_chain_provider: None,
1569            audit_verifier: None,
1570            slow_query_threshold_us: None,
1571            slow_query_logger: None,
1572            session_params: BTreeMap::new(),
1573            cursors: BTreeMap::new(),
1574            last_insert_id: core::sync::atomic::AtomicI64::new(0),
1575            row_count: 0,
1576            user_vars: BTreeMap::new(),
1577            temp_tables: BTreeSet::new(),
1578            temp_sequences: BTreeSet::new(),
1579            temp_views: BTreeSet::new(),
1580            listen_channels: BTreeSet::new(),
1581            tx_pending_notifies: Vec::new(),
1582            delivered_notifies: Vec::new(),
1583            pending_notices: Vec::new(),
1584            spill_stats: crate::tempstore::SpillStats::default(),
1585            xact_commit: core::sync::atomic::AtomicU64::new(0),
1586            xact_rollback: core::sync::atomic::AtomicU64::new(0),
1587            backend_count_fn: None,
1588            backend_pid_fn: None,
1589            wal_lsn_fn: None,
1590            backend_signal_fn: None,
1591            tz_offset_fn: None,
1592            tz_localize_fn: None,
1593            tz_canon_fn: None,
1594            tz_abbrev_fn: None,
1595            tz_all_fn: None,
1596            stat_tup_inserted: 0,
1597            table_write_stats: alloc::collections::BTreeMap::new(),
1598            commit_epoch: 0,
1599            stat_tup_updated: 0,
1600            stat_tup_deleted: 0,
1601            local_guc_saves: Vec::new(),
1602            render_style: crate::eval::RenderStyle::default(),
1603            savepoint_guc_marks: Vec::new(),
1604            trigger_recursion_depth: 0,
1605            rule_rewrite_active: false,
1606            foreign_key_checks: true,
1607            meta_views_materialised: false,
1608            pending_foreign_keys: Vec::new(),
1609            env_cfg: testkit::EnvConfig::default(),
1610            #[cfg(feature = "injection-points")]
1611            injection_store: alloc::sync::Arc::new(
1612                crate::testkit::injection::InjectionStore::default(),
1613            ),
1614            redo_capture: false,
1615            current_isolation_level: spg_sql::ast::IsolationLevel::ReadCommitted,
1616            last_redo: Vec::new(),
1617            table_change_seq: alloc::collections::BTreeMap::new(),
1618            matview_refresh_watermark: alloc::collections::BTreeMap::new(),
1619            matview_maintainable: alloc::collections::BTreeMap::new(),
1620            matview_delta_buf: alloc::collections::BTreeMap::new(),
1621            matview_delta_overflow: alloc::collections::BTreeSet::new(),
1622            matview_row_map: alloc::collections::BTreeMap::new(),
1623        }
1624    }
1625
1626    /// v7.11.0 — clone the engine's committed catalog + read-time
1627    /// state into a frozen `CatalogSnapshot`. Cheap (`Catalog` is
1628    /// backed by `PersistentVec`; cloning is O(log n) per table).
1629    /// Subsequent writes to this engine are invisible to the
1630    /// snapshot; the snapshot is self-contained and can be moved
1631    /// to another thread for concurrent `execute_readonly_on_snapshot`
1632    /// calls. The basis for [`AsyncReadHandle`] in spg-embedded-tokio
1633    /// and any other read-fanout pattern.
1634    #[must_use]
1635    pub fn clone_snapshot(&self) -> CatalogSnapshot {
1636        CatalogSnapshot {
1637            catalog: self.active_catalog().clone(),
1638            statistics: self.statistics.clone(),
1639            clock: self.clock,
1640            max_query_rows: self.max_query_rows,
1641        }
1642    }
1643
1644    /// v7.39 (round 513) — does this role exist? `'x'::regrole` needs it,
1645    /// and roles live on the engine rather than the catalog.
1646    #[must_use]
1647    pub fn role_exists(&self, name: &str) -> bool {
1648        // v7.39 (round 696) — the SESSION's own identity, same class as the
1649        // `postgres` case below and missed by it. `current_user` reported
1650        // the connected name while this predicate denied it, so `SET ROLE
1651        // <me>` refused the role the session was already running as.
1652        if name == self.session_user() {
1653            return true;
1654        }
1655        // The engine's default identity exists even before any CREATE USER.
1656        //
1657        // v7.39 (round 652) — and so does `postgres`. `synth_pg_roles`
1658        // has always inserted it as the bootstrap superuser when no user
1659        // by that name was created, so this predicate and the catalogue
1660        // it is supposed to reflect disagreed: `pg_roles` listed
1661        // `postgres` while `'postgres'::regrole` said it did not exist.
1662        // Every pg_dump names it (`OWNER TO postgres`), so the ALTER
1663        // TABLE OWNER check added this round would have refused the one
1664        // role that appears in essentially every dump.
1665        self.effective_users().contains(name)
1666            || name.eq_ignore_ascii_case("admin")
1667            || name.eq_ignore_ascii_case("postgres")
1668            // 7.38.1 S5.2 — PG's predefined pg_* roles exist without
1669            // anyone creating them, and pg_dump's ACL section GRANTs
1670            // to `pg_database_owner` on every dump of `public`.
1671            || matches!(
1672                name.to_ascii_lowercase().as_str(),
1673                "pg_database_owner"
1674                    | "pg_read_all_data"
1675                    | "pg_write_all_data"
1676                    | "pg_monitor"
1677                    | "pg_read_all_settings"
1678                    | "pg_read_all_stats"
1679                    | "pg_stat_scan_tables"
1680                    | "pg_signal_backend"
1681                    | "pg_checkpoint"
1682                    | "pg_maintain"
1683                    | "pg_use_reserved_connections"
1684                    | "pg_create_subscription"
1685            )
1686    }
1687
1688    /// v7.39 (round 520) — the role an oid names, as `pg_get_userbyid`
1689    /// reports it. The numbering is `synth_pg_roles`': base 10, one per
1690    /// user in catalog order.
1691    #[must_use]
1692    pub fn role_name_for_oid(&self, oid: i64) -> Option<String> {
1693        // Oid 10 is the bootstrap superuser, which `synth_pg_roles` always
1694        // publishes as `postgres`. Following the catalogue rather than the
1695        // session is the point: a join on `relowner = pg_roles.oid` and
1696        // `pg_get_userbyid(relowner)` have to name the same role.
1697        if oid == 10 {
1698            return Some(alloc::string::String::from("postgres"));
1699        }
1700        let idx = usize::try_from(oid - 11).ok()?;
1701        self.users
1702            .iter()
1703            .nth(idx)
1704            .map(|(n, _)| alloc::string::String::from(n))
1705    }
1706
1707    /// v7.37.15 (Phase B / C / E) — current per-row visibility
1708    /// snapshot for in-engine scans. Captures the live writer-
1709    /// version cursor + active-writer set; readers built from this
1710    /// Snapshot see committed state through the moment of capture
1711    /// and DO NOT observe uncommitted writes still inside
1712    /// `active_writer_versions`.
1713    ///
1714    /// Phase E: if there's an explicit transaction in flight under
1715    /// REPEATABLE READ or SERIALIZABLE isolation, returns the
1716    /// snapshot the tx cached at BEGIN time — every statement in
1717    /// the tx sees the same coherent prior-committed view. READ
1718    /// COMMITTED (the default) returns a fresh snapshot per call,
1719    /// matching PG's per-statement visibility semantics.
1720    ///
1721    /// `oldest_active = version` when no writer is in flight (== no
1722    /// dead row could still be observed); else == min of active
1723    /// versions (vacuum-floor).
1724    #[must_use]
1725    pub fn current_snapshot(&self) -> spg_storage::snapshot::Snapshot {
1726        // v7.39 (round 297, E3 Phase 1b) — carry the SKIP LOCKED
1727        // exclusions on the snapshot. Every row source threads a
1728        // snapshot through `is_row_visible`, so this is the one place
1729        // that cannot be routed around; adding the filter per scan site
1730        // missed the live path three times.
1731        let locked_out = self.lock_skip_rows.as_ref().and_then(|(t, set)| {
1732            self.active_catalog()
1733                .get(t)
1734                .map(|tbl| (tbl.rel_id(), set.clone()))
1735        });
1736        let mut snap = self.current_snapshot_inner();
1737        snap.locked_out = locked_out;
1738        snap
1739    }
1740
1741    fn current_snapshot_inner(&self) -> spg_storage::snapshot::Snapshot {
1742        // Phase E — if we're inside a RR/SER tx, return its
1743        // cached snapshot so the whole tx sees one frozen view.
1744        if let Some(tx_id) = self.current_tx
1745            && let Some(state) = self.tx_catalogs.get(&tx_id)
1746            && let Some(s) = state.cached_snapshot.as_ref()
1747        {
1748            return s.clone();
1749        }
1750        // v7.37.15 (Phase C.3, step 1) — carry the current tx's writer
1751        // version as the snapshot's `tx_id` so the visibility gate's
1752        // self-write branch (`visible` step 1) recognises rows this
1753        // transaction stamped (`xmin == v`, with `v` in
1754        // `active_writer_versions`). Without this the tx's own
1755        // uncommitted rows fall to the in-progress step and become
1756        // invisible to itself on the gated read paths. Autocommit reads
1757        // (no `tx_writer_versions` entry) keep `tx_id = 0`.
1758        let reader_tx_id = self
1759            .current_tx
1760            .and_then(|t| self.tx_writer_versions.get(&t).copied())
1761            .unwrap_or(0);
1762        let version = spg_storage::row_header::current_version();
1763        if self.active_writer_versions.is_empty() {
1764            // Hot path: no writer in flight. Snapshot::unbounded()
1765            // would also work, but pinning to the live cursor
1766            // means the snapshot's oldest_active is accurate
1767            // (= version) so subsequent vacuum can advance.
1768            return spg_storage::snapshot::Snapshot::new(
1769                version,
1770                spg_storage::snapshot::InProgressSet::empty(),
1771                version,
1772                reader_tx_id,
1773            );
1774        }
1775        let sorted: alloc::vec::Vec<u64> = self.active_writer_versions.iter().copied().collect();
1776        let oldest = *sorted.first().unwrap_or(&version);
1777        spg_storage::snapshot::Snapshot::new(
1778            version,
1779            spg_storage::snapshot::InProgressSet::from_sorted(sorted),
1780            oldest,
1781            reader_tx_id,
1782        )
1783    }
1784
1785    /// v7.37.15 (Phase C) — allocate the next writer version AND
1786    /// add it to the in-flight set so concurrent snapshots hide
1787    /// the resulting writes until [`Self::commit_writer_version`]
1788    /// removes the entry. Returns the allocated version so the
1789    /// writer can stamp it on `xmin` / `xmax`.
1790    pub fn begin_writer_version(&mut self) -> u64 {
1791        let v = spg_storage::row_header::next_version();
1792        self.active_writer_versions.insert(v);
1793        v
1794    }
1795
1796    /// v7.37.15 (Phase C) — mark a previously-allocated writer
1797    /// version as committed. Subsequent snapshots stop including
1798    /// it in `in_progress`, so the writes the version stamped
1799    /// become visible to new readers.
1800    ///
1801    /// No-op if the version was never allocated; matches PG's
1802    /// idempotent `TransactionIdCommitTree` semantics.
1803    pub fn commit_writer_version(&mut self, v: u64) {
1804        self.active_writer_versions.remove(&v);
1805    }
1806
1807    /// v7.37.15 (Phase C.2) — mark a previously-allocated writer
1808    /// version as ABORTED (rolled back). Removes it from the in-flight
1809    /// set and records it in `aborted_versions` so the visibility
1810    /// oracle ([`Self::xact_status`]) reports `Aborted` rather than
1811    /// silently treating it as committed once it leaves the in-flight
1812    /// set. Phase C.3's in-place write path relies on this: a
1813    /// rolled-back version's xmin/xmax stamps stay physically present
1814    /// until vacuum reclaims them, and readers must NOT see them.
1815    ///
1816    /// Idempotent; a no-op if the version was never allocated.
1817    pub fn abort_writer_version(&mut self, v: u64) {
1818        self.active_writer_versions.remove(&v);
1819        self.aborted_versions.insert(v);
1820    }
1821
1822    /// v7.37.15 (Phase C.2) — the visibility oracle's terminal-status
1823    /// lookup for one version. In-flight if still allocated, Aborted
1824    /// if it rolled back, otherwise Committed (the default for a
1825    /// version that left the in-flight set the normal way, and for
1826    /// every frozen / pruned old version the engine no longer tracks).
1827    ///
1828    /// `aborted_versions` is bounded by pruning below `oldest_active`
1829    /// during vacuum (Phase D): once no live snapshot can still see an
1830    /// aborted version's stamps, its entry is dropped. Until Phase D
1831    /// lands the set only grows with rolled-back transactions — noted
1832    /// as a never-die follow-up, not a steady-state leak on the
1833    /// commit path.
1834    #[must_use]
1835    pub fn xact_status(&self, v: u64) -> spg_storage::snapshot::XactStatus {
1836        use spg_storage::snapshot::XactStatus;
1837        if self.active_writer_versions.contains(&v) {
1838            XactStatus::InProgress
1839        } else if self.aborted_versions.contains(&v) {
1840            XactStatus::Aborted
1841        } else {
1842            XactStatus::Committed
1843        }
1844    }
1845
1846    /// v7.37.15 (Phase C.4) — acquire a tuple lock on a stable
1847    /// `(RelId, RowId)` for writer `version`. The in-place write path
1848    /// (C.3) calls this before stamping xmax; `SELECT ... FOR UPDATE`
1849    /// wires here via the parser's lock-strength clause (C.4). Returns
1850    /// the [`LockOutcome`](crate::locks::LockOutcome) the caller acts on
1851    /// (grant / park / skip / fail / deadlock-abort).
1852    pub fn acquire_row_lock(
1853        &mut self,
1854        rel: spg_storage::row_header::RelId,
1855        row: spg_storage::row_header::RowId,
1856        mode: crate::locks::LockMode,
1857        version: u64,
1858        policy: crate::locks::WaitPolicy,
1859    ) -> crate::locks::LockOutcome {
1860        self.locks.acquire(rel, row, mode, version, policy)
1861    }
1862
1863    /// v7.37.15 (Phase C.4) — release every lock + wait held by
1864    /// `version` at transaction end. Called from `exec_commit` /
1865    /// `exec_rollback` alongside the writer-version bookkeeping.
1866    pub fn release_tx_locks(&mut self, version: u64) {
1867        self.locks.release_all(version);
1868    }
1869
1870    /// 7.38.1 S2.1 — drop the tuple locks an AUTOCOMMIT statement took
1871    /// (its implicit transaction ends with it). A no-op inside an open
1872    /// transaction (those release at COMMIT/ROLLBACK) and when the
1873    /// statement allocated no writer version.
1874    pub(crate) fn release_autocommit_stmt_locks(&mut self) {
1875        let in_tx = self
1876            .current_tx
1877            .is_some_and(|tx| self.tx_writer_versions.contains_key(&tx));
1878        if in_tx {
1879            return;
1880        }
1881        if let Some(v) = self.stmt_writer_version {
1882            self.locks.release_all(v);
1883        }
1884    }
1885
1886    /// v7.37.15 (Phase C.4) — number of rows currently locked, for the
1887    /// `pg_locks` enumeration and tests.
1888    #[must_use]
1889    pub fn locked_row_count(&self) -> usize {
1890        self.locks.locked_row_count()
1891    }
1892
1893    /// v7.37.15 (Phase C.3) — is the in-place MVCC write path enabled?
1894    /// `false` (default) keeps legacy physical DELETE/UPDATE. The C.3
1895    /// writers consult this to choose tombstone-vs-physical.
1896    #[must_use]
1897    pub fn mvcc_inplace(&self) -> bool {
1898        self.mvcc_inplace
1899    }
1900
1901    /// v7.37.15 (Phase C.3) — enable/disable the in-place MVCC write
1902    /// path. Called by the host after reading `SPG_MVCC_INPLACE` (the
1903    /// `no_std` engine can't read the environment itself). Off until
1904    /// the write path is proven against PG18 differential tests.
1905    pub fn set_mvcc_inplace(&mut self, on: bool) {
1906        self.mvcc_inplace = on;
1907    }
1908
1909    /// v7.39 (parallel-agg P0) — inject the host's parallel executor
1910    /// (see [`ParallelRunner`]). Called once at host startup; the
1911    /// engine stays single-threaded without it.
1912    /// v7.39 (pg_stat knife A) — inject the host's live backend count.
1913    pub fn set_backend_count_fn(&mut self, f: BackendCountFn) {
1914        self.backend_count_fn = Some(f);
1915    }
1916
1917    /// v7.39 (read01 pgstatfuncs.c) — inject the host's calling-connection
1918    /// identity for pg_backend_pid().
1919    /// v7.39 (round 476) — register the WAL byte-position provider.
1920    pub fn set_wal_lsn_fn(&mut self, f: WalLsnFn) {
1921        self.wal_lsn_fn = Some(f);
1922    }
1923
1924    pub fn set_backend_pid_fn(&mut self, f: BackendPidFn) {
1925        self.backend_pid_fn = Some(f);
1926    }
1927
1928    /// v7.39 (round 318, V51) — inject the host's connection-control hook,
1929    /// so `pg_cancel_backend` / `pg_terminate_backend` / `KILL` act instead
1930    /// of answering a constant.
1931    pub fn set_backend_signal_fn(&mut self, f: BackendSignalFn) {
1932        self.backend_signal_fn = Some(f);
1933    }
1934
1935    /// v7.39 (round 786, T35 Phase A) — install the host's spill-run
1936    /// factory. Without one the engine cannot spill and a sort that
1937    /// outgrows `max_query_bytes` keeps refusing, which is exactly the
1938    /// behaviour every caller has today.
1939    pub fn set_temp_run_factory(&mut self, f: crate::TempRunFactory) {
1940        self.temp_run_factory = Some(f);
1941    }
1942
1943    /// Whether spilling is available in this process.
1944    #[must_use]
1945    pub fn can_spill(&self) -> bool {
1946        self.temp_run_factory.is_some()
1947    }
1948
1949    /// v7.39 (round 786) — open a fresh spill run, or `None` when no
1950    /// host factory is installed. Phase B's run generation calls this;
1951    /// it lives here so the `None` path stays a single decision point.
1952    pub(crate) fn open_temp_run(
1953        &self,
1954    ) -> Option<Result<alloc::boxed::Box<dyn crate::TempRun>, crate::TempStoreError>> {
1955        self.temp_run_factory.map(|f| f())
1956    }
1957
1958    /// v7.39 (tz epic) — inject the host's IANA timezone lookups
1959    /// (spg-tzif's fn family on std hosts).
1960    pub fn set_tz_fns(
1961        &mut self,
1962        offset: TzOffsetFn,
1963        localize: TzLocalizeFn,
1964        canon: TzCanonFn,
1965        abbrev: TzAbbrevFn,
1966    ) {
1967        self.tz_offset_fn = Some(offset);
1968        self.tz_localize_fn = Some(localize);
1969        self.tz_canon_fn = Some(canon);
1970        self.tz_abbrev_fn = Some(abbrev);
1971    }
1972
1973    /// v7.39 (round 502) — the zone enumerator behind `pg_timezone_names`.
1974    /// Separate from `set_tz_fns` so an embedder that already calls that
1975    /// one keeps compiling.
1976    pub fn set_tz_all_fn(&mut self, all: TzAllFn) {
1977        self.tz_all_fn = Some(all);
1978    }
1979
1980    /// Every zone the host knows at `utc_micros`; empty without a hook.
1981    pub(crate) fn tz_all_at(
1982        &self,
1983        utc_micros: i64,
1984    ) -> alloc::vec::Vec<(alloc::string::String, alloc::string::String, i64, bool)> {
1985        self.tz_all_fn
1986            .map_or_else(alloc::vec::Vec::new, |f| f(utc_micros))
1987    }
1988
1989    pub fn set_parallel_runner(&mut self, runner: alloc::sync::Arc<dyn ParallelRunner>) {
1990        self.parallel_runner = ParallelRunnerSlot(Some(runner));
1991    }
1992
1993    /// v7.37.15 (Phase C) — allocate a fresh version number for
1994    /// the next write. Always strictly monotonic + process-wide
1995    /// shared so concurrent engines on the same process agree on
1996    /// "tx 17 commits before tx 18". Phase C writer paths call
1997    /// this once per INSERT / UPDATE / DELETE statement to obtain
1998    /// the version they'll stamp on the new row's `xmin` (or the
1999    /// existing row's `xmax`).
2000    ///
2001    /// Returns [`XMIN_FROZEN`] when MVCC stamping is intentionally
2002    /// off (legacy `in_memory` flow / WAL replay): the writer
2003    /// then takes the legacy frozen-insert short-circuit path
2004    /// inside `Table::insert_with_xmin`.
2005    #[must_use]
2006    pub fn next_writer_version(&self) -> u64 {
2007        spg_storage::row_header::next_version()
2008    }
2009
2010    /// v7.37.15 (Phase C) — version a writer should stamp on
2011    /// rows produced by the current statement. Inside an explicit
2012    /// transaction the version is the tx's pre-allocated one (so
2013    /// every statement in the tx commits atomically at COMMIT);
2014    /// in autocommit it allocates a fresh version per statement.
2015    ///
2016    /// This is the canonical helper engine writers should call
2017    /// — using it instead of `next_writer_version` ensures
2018    /// explicit-tx semantics where every row produced by the tx
2019    /// shares one xmin and concurrent readers don't see partial
2020    /// state until COMMIT.
2021    ///
2022    /// v7.37.15 (Epic W slice 2) — takes `&mut self` so the autocommit
2023    /// branch can **memoize** its freshly-minted version in
2024    /// `stmt_writer_version`. `next_writer_version()` is a `fetch_add`,
2025    /// so without memoization a second call within one statement (the
2026    /// redo drain post-stamps the captured `RowChange`s) would allocate
2027    /// a *different* version than the writes used. Memoizing makes the
2028    /// value stable for the statement's lifetime; it is reset per
2029    /// `execute_in_with_cancel`, so the counter still advances exactly
2030    /// once per autocommit statement — identical to before.
2031    pub fn writer_version_for_current_stmt(&mut self) -> u64 {
2032        if let Some(tx_id) = self.current_tx
2033            && let Some(&v) = self.tx_writer_versions.get(&tx_id)
2034        {
2035            return v;
2036        }
2037        // Autocommit shape: fresh version, immediately "committed"
2038        // (no entry in active_writer_versions, so subsequent
2039        // readers see the row). Memoized for the statement so the
2040        // redo drain reads back the same version the writes used.
2041        if let Some(v) = self.stmt_writer_version {
2042            return v;
2043        }
2044        let v = self.next_writer_version();
2045        self.stmt_writer_version = Some(v);
2046        v
2047    }
2048
2049    /// Construct an engine restored from a previously-snapshotted catalog
2050    /// (see `snapshot()`).
2051    pub fn restore(catalog: Catalog) -> Self {
2052        Self {
2053            lock_skip_rows: None,
2054            catalog,
2055            parallel_runner: ParallelRunnerSlot::default(),
2056            tx_catalogs: BTreeMap::new(),
2057            table_last_commit: BTreeMap::new(),
2058            commit_seq: 0,
2059            current_tx: None,
2060            backslash_escapes: false,
2061            mysql_strict: true,
2062            lo_descriptors: BTreeMap::new(),
2063            lo_next_fd: 0,
2064            prepared_statements: alloc::collections::BTreeMap::new(),
2065            current_session: 0,
2066            sessions: BTreeMap::new(),
2067            advisory_locks: BTreeMap::new(),
2068            last_sequence_used: None,
2069            seq_currvals: alloc::collections::BTreeMap::new(),
2070            next_tx_id: 1,
2071            active_writer_versions: BTreeSet::new(),
2072            aborted_versions: BTreeSet::new(),
2073            locks: crate::locks::LockTable::new(),
2074            mvcc_inplace: !cfg!(feature = "mvcc-inplace-off"),
2075            autovacuum: true,
2076            autovacuum_inline: true,
2077            tx_writer_versions: BTreeMap::new(),
2078            stmt_writer_version: None,
2079            clock: None,
2080            salt_fn: None,
2081            max_query_rows: None,
2082            max_query_bytes: None,
2083            temp_run_factory: None,
2084            users: UserStore::new(),
2085            publications: publications::Publications::new(),
2086            subscriptions: subscriptions::Subscriptions::new(),
2087            statistics: statistics::Statistics::new(),
2088            plan_cache: plan_cache::PlanCache::new(),
2089            query_stats: query_stats::QueryStats::new(),
2090            activity_provider: None,
2091            audit_chain_provider: None,
2092            audit_verifier: None,
2093            slow_query_threshold_us: None,
2094            slow_query_logger: None,
2095            session_params: BTreeMap::new(),
2096            cursors: BTreeMap::new(),
2097            last_insert_id: core::sync::atomic::AtomicI64::new(0),
2098            row_count: 0,
2099            user_vars: BTreeMap::new(),
2100            temp_tables: BTreeSet::new(),
2101            temp_sequences: BTreeSet::new(),
2102            temp_views: BTreeSet::new(),
2103            listen_channels: BTreeSet::new(),
2104            tx_pending_notifies: Vec::new(),
2105            delivered_notifies: Vec::new(),
2106            pending_notices: Vec::new(),
2107            spill_stats: crate::tempstore::SpillStats::default(),
2108            xact_commit: core::sync::atomic::AtomicU64::new(0),
2109            xact_rollback: core::sync::atomic::AtomicU64::new(0),
2110            backend_count_fn: None,
2111            backend_pid_fn: None,
2112            wal_lsn_fn: None,
2113            backend_signal_fn: None,
2114            tz_offset_fn: None,
2115            tz_localize_fn: None,
2116            tz_canon_fn: None,
2117            tz_abbrev_fn: None,
2118            tz_all_fn: None,
2119            stat_tup_inserted: 0,
2120            table_write_stats: alloc::collections::BTreeMap::new(),
2121            commit_epoch: 0,
2122            stat_tup_updated: 0,
2123            stat_tup_deleted: 0,
2124            local_guc_saves: Vec::new(),
2125            render_style: crate::eval::RenderStyle::default(),
2126            savepoint_guc_marks: Vec::new(),
2127            trigger_recursion_depth: 0,
2128            rule_rewrite_active: false,
2129            foreign_key_checks: true,
2130            meta_views_materialised: false,
2131            pending_foreign_keys: Vec::new(),
2132            env_cfg: testkit::EnvConfig::default(),
2133            #[cfg(feature = "injection-points")]
2134            injection_store: alloc::sync::Arc::new(
2135                crate::testkit::injection::InjectionStore::default(),
2136            ),
2137            redo_capture: false,
2138            current_isolation_level: spg_sql::ast::IsolationLevel::ReadCommitted,
2139            last_redo: Vec::new(),
2140            table_change_seq: alloc::collections::BTreeMap::new(),
2141            matview_refresh_watermark: alloc::collections::BTreeMap::new(),
2142            matview_maintainable: alloc::collections::BTreeMap::new(),
2143            matview_delta_buf: alloc::collections::BTreeMap::new(),
2144            matview_delta_overflow: alloc::collections::BTreeSet::new(),
2145            matview_row_map: alloc::collections::BTreeMap::new(),
2146        }
2147    }
2148
2149    /// Restore an engine + user table from a v4.1 envelope produced
2150    /// by `snapshot_with_users()`. Falls back to plain catalog-only
2151    /// restore if the envelope magic isn't present (so v3.x snapshot
2152    /// files still load). v6.1.2 adds the optional publications
2153    /// trailer (envelope v3); a v1/v2 envelope deserialises to an
2154    /// empty publication table.
2155    pub fn restore_envelope(buf: &[u8]) -> Result<Self, EngineError> {
2156        match split_envelope(buf) {
2157            EnvelopeParse::Pair {
2158                catalog: catalog_bytes,
2159                users: user_bytes,
2160                publications: pub_bytes,
2161                subscriptions: sub_bytes,
2162                statistics: stats_bytes,
2163            } => {
2164                let mut catalog =
2165                    Catalog::deserialize(catalog_bytes).map_err(EngineError::Storage)?;
2166                crate::ddl::rebuild_all_excl_indexes(&mut catalog);
2167                // v7.38.16 — and refill the expression indexes, whose keys
2168                // the format cannot carry: what is on disk under one was
2169                // written by a version that stored the wrong values there.
2170                crate::expr_index::rebuild_all(&mut catalog);
2171                let users = users::deserialize_users(user_bytes)
2172                    .map_err(|e| EngineError::Unsupported(alloc::format!("users restore: {e}")))?;
2173                let publications = match pub_bytes {
2174                    Some(b) => publications::Publications::deserialize(b).map_err(|e| {
2175                        EngineError::Unsupported(alloc::format!("publications restore: {e:?}"))
2176                    })?,
2177                    None => publications::Publications::new(),
2178                };
2179                let subscriptions = match sub_bytes {
2180                    Some(b) => subscriptions::Subscriptions::deserialize(b).map_err(|e| {
2181                        EngineError::Unsupported(alloc::format!("subscriptions restore: {e:?}"))
2182                    })?,
2183                    None => subscriptions::Subscriptions::new(),
2184                };
2185                let statistics = match stats_bytes {
2186                    Some(b) => statistics::Statistics::deserialize(b).map_err(|e| {
2187                        EngineError::Unsupported(alloc::format!("statistics restore: {e:?}"))
2188                    })?,
2189                    None => statistics::Statistics::new(),
2190                };
2191                Ok(Self {
2192                    lock_skip_rows: None,
2193                    catalog,
2194                    parallel_runner: ParallelRunnerSlot::default(),
2195                    tx_catalogs: BTreeMap::new(),
2196                    table_last_commit: BTreeMap::new(),
2197                    commit_seq: 0,
2198                    current_tx: None,
2199                    backslash_escapes: false,
2200                    mysql_strict: true,
2201                    lo_descriptors: BTreeMap::new(),
2202                    lo_next_fd: 0,
2203                    prepared_statements: alloc::collections::BTreeMap::new(),
2204                    current_session: 0,
2205                    sessions: BTreeMap::new(),
2206                    advisory_locks: BTreeMap::new(),
2207                    last_sequence_used: None,
2208                    seq_currvals: alloc::collections::BTreeMap::new(),
2209                    next_tx_id: 1,
2210                    active_writer_versions: BTreeSet::new(),
2211                    aborted_versions: BTreeSet::new(),
2212                    locks: crate::locks::LockTable::new(),
2213                    mvcc_inplace: !cfg!(feature = "mvcc-inplace-off"),
2214                    autovacuum: true,
2215                    autovacuum_inline: true,
2216                    tx_writer_versions: BTreeMap::new(),
2217                    stmt_writer_version: None,
2218                    clock: None,
2219                    salt_fn: None,
2220                    max_query_rows: None,
2221                    max_query_bytes: None,
2222                    temp_run_factory: None,
2223                    users,
2224                    publications,
2225                    subscriptions,
2226                    statistics,
2227                    plan_cache: plan_cache::PlanCache::new(),
2228                    query_stats: query_stats::QueryStats::new(),
2229                    activity_provider: None,
2230                    audit_chain_provider: None,
2231                    audit_verifier: None,
2232                    slow_query_threshold_us: None,
2233                    slow_query_logger: None,
2234                    session_params: BTreeMap::new(),
2235                    cursors: BTreeMap::new(),
2236                    last_insert_id: core::sync::atomic::AtomicI64::new(0),
2237                    row_count: 0,
2238                    user_vars: BTreeMap::new(),
2239                    temp_tables: BTreeSet::new(),
2240                    temp_sequences: BTreeSet::new(),
2241                    temp_views: BTreeSet::new(),
2242                    listen_channels: BTreeSet::new(),
2243                    tx_pending_notifies: Vec::new(),
2244                    delivered_notifies: Vec::new(),
2245                    pending_notices: Vec::new(),
2246                    spill_stats: crate::tempstore::SpillStats::default(),
2247                    xact_commit: core::sync::atomic::AtomicU64::new(0),
2248                    xact_rollback: core::sync::atomic::AtomicU64::new(0),
2249                    backend_count_fn: None,
2250                    backend_pid_fn: None,
2251                    wal_lsn_fn: None,
2252                    backend_signal_fn: None,
2253                    tz_offset_fn: None,
2254                    tz_localize_fn: None,
2255                    tz_canon_fn: None,
2256                    tz_abbrev_fn: None,
2257                    tz_all_fn: None,
2258                    stat_tup_inserted: 0,
2259                    table_write_stats: alloc::collections::BTreeMap::new(),
2260                    commit_epoch: 0,
2261                    stat_tup_updated: 0,
2262                    stat_tup_deleted: 0,
2263                    local_guc_saves: Vec::new(),
2264                    render_style: crate::eval::RenderStyle::default(),
2265                    savepoint_guc_marks: Vec::new(),
2266                    trigger_recursion_depth: 0,
2267                    rule_rewrite_active: false,
2268                    foreign_key_checks: true,
2269                    meta_views_materialised: false,
2270                    pending_foreign_keys: Vec::new(),
2271                    env_cfg: testkit::EnvConfig::default(),
2272                    #[cfg(feature = "injection-points")]
2273                    injection_store: alloc::sync::Arc::new(
2274                        crate::testkit::injection::InjectionStore::default(),
2275                    ),
2276                    redo_capture: false,
2277                    current_isolation_level: spg_sql::ast::IsolationLevel::ReadCommitted,
2278                    last_redo: Vec::new(),
2279                    table_change_seq: alloc::collections::BTreeMap::new(),
2280                    matview_refresh_watermark: alloc::collections::BTreeMap::new(),
2281                    matview_maintainable: alloc::collections::BTreeMap::new(),
2282                    matview_delta_buf: alloc::collections::BTreeMap::new(),
2283                    matview_delta_overflow: alloc::collections::BTreeSet::new(),
2284                    matview_row_map: alloc::collections::BTreeMap::new(),
2285                })
2286            }
2287            EnvelopeParse::CrcMismatch { expected, computed } => {
2288                Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
2289                    "snapshot envelope CRC32 mismatch (expected={expected:#010x}, computed={computed:#010x})"
2290                ))))
2291            }
2292            EnvelopeParse::Bare => {
2293                let mut catalog = Catalog::deserialize(buf).map_err(EngineError::Storage)?;
2294                crate::ddl::rebuild_all_excl_indexes(&mut catalog);
2295                // v7.38.16 — and refill the expression indexes, whose keys
2296                // the format cannot carry: what is on disk under one was
2297                // written by a version that stored the wrong values there.
2298                crate::expr_index::rebuild_all(&mut catalog);
2299                Ok(Self::restore(catalog))
2300            }
2301        }
2302    }
2303
2304    pub const fn users(&self) -> &UserStore {
2305        &self.users
2306    }
2307
2308    /// Builder: attach a wall clock so `NOW()` / `CURRENT_TIMESTAMP` /
2309    /// `CURRENT_DATE` evaluate to a real value instead of erroring out.
2310    /// v7.39 (round 279) — announce which connection is about to run.
2311    /// The server calls this before every statement; embedded hosts
2312    /// never do and stay on session 0.
2313    ///
2314    /// Swapping parks the outgoing connection's state and installs the
2315    /// incoming one's, creating it on first sight. The plan cache is
2316    /// cleared because the string-literal dialect is part of what
2317    /// swaps and the same SQL text lexes differently under it.
2318    pub fn set_current_session(&mut self, id: u32) {
2319        if id == self.current_session {
2320            return;
2321        }
2322        let outgoing = SessionBag {
2323            session_params: core::mem::take(&mut self.session_params),
2324            backslash_escapes: self.backslash_escapes,
2325            mysql_strict: self.mysql_strict,
2326            prepared_statements: core::mem::take(&mut self.prepared_statements),
2327            lo_descriptors: core::mem::take(&mut self.lo_descriptors),
2328            lo_next_fd: self.lo_next_fd,
2329            cursors: core::mem::take(&mut self.cursors),
2330            last_insert_id: self
2331                .last_insert_id
2332                .load(core::sync::atomic::Ordering::Relaxed),
2333            row_count: self.row_count,
2334            user_vars: core::mem::take(&mut self.user_vars),
2335            temp_tables: core::mem::take(&mut self.temp_tables),
2336            temp_sequences: core::mem::take(&mut self.temp_sequences),
2337            temp_views: core::mem::take(&mut self.temp_views),
2338            seq_currvals: core::mem::take(&mut self.seq_currvals),
2339            last_sequence_used: self.last_sequence_used.take(),
2340            isolation_level: self.current_isolation_level,
2341        };
2342        self.sessions.insert(self.current_session, outgoing);
2343        let incoming = self.sessions.remove(&id).unwrap_or_default();
2344        self.session_params = incoming.session_params;
2345        self.backslash_escapes = incoming.backslash_escapes;
2346        self.mysql_strict = incoming.mysql_strict;
2347        self.prepared_statements = incoming.prepared_statements;
2348        self.lo_descriptors = incoming.lo_descriptors;
2349        self.lo_next_fd = incoming.lo_next_fd;
2350        self.cursors = incoming.cursors;
2351        self.last_insert_id.store(
2352            incoming.last_insert_id,
2353            core::sync::atomic::Ordering::Relaxed,
2354        );
2355        self.row_count = incoming.row_count;
2356        self.user_vars = incoming.user_vars;
2357        self.temp_tables = incoming.temp_tables;
2358        self.temp_sequences = incoming.temp_sequences;
2359        self.temp_views = incoming.temp_views;
2360        self.seq_currvals = incoming.seq_currvals;
2361        self.last_sequence_used = incoming.last_sequence_used;
2362        self.current_isolation_level = incoming.isolation_level;
2363        self.current_session = id;
2364        // The incoming session's temp namespace must be live before its very
2365        // first statement resolves a name.
2366        self.refresh_temp_prefix();
2367        self.plan_cache.clear();
2368    }
2369
2370    /// v7.39 (round 436) — the catalog-name prefix session `id` stores its
2371    /// TEMPORARY tables under. Mirrors PG's per-session `pg_temp_N` schema;
2372    /// the leading underscores keep it out of any name a client can write.
2373    fn temp_prefix_for(id: u32) -> String {
2374        alloc::format!("__spg_temp_{id}__")
2375    }
2376
2377    /// v7.38.14 — which session's temporary namespace does this catalog
2378    /// name belong to, if any? The inverse of `session_temp_name`, so the
2379    /// system catalog can report `pg_temp_N` for it instead of `public`.
2380    pub(crate) fn temp_session_of(catalog_name: &str) -> Option<u32> {
2381        let rest = catalog_name.strip_prefix("__spg_temp_")?;
2382        let (id, _) = rest.split_once("__")?;
2383        id.parse().ok()
2384    }
2385
2386    /// The catalog name this session's TEMPORARY table `logical` takes.
2387    pub(crate) fn session_temp_name(&self, logical: &str) -> String {
2388        alloc::format!("{}{logical}", Self::temp_prefix_for(self.current_session))
2389    }
2390
2391    /// v7.39 (round 436) — point every catalog this session can reach at its
2392    /// temp namespace, or at none when it owns no temporary tables (so a
2393    /// session that never made one pays a single `Option` check per lookup).
2394    /// Both the committed catalog and any open transaction's shadow are set:
2395    /// a temp table created inside a transaction must resolve there too.
2396    pub(crate) fn refresh_temp_prefix(&mut self) {
2397        let prefix = if self.temp_tables.is_empty()
2398            && self.temp_sequences.is_empty()
2399            && self.temp_views.is_empty()
2400        {
2401            None
2402        } else {
2403            Some(Self::temp_prefix_for(self.current_session))
2404        };
2405        self.catalog.set_temp_prefix(prefix.clone());
2406        for shadow in self.tx_catalogs.values_mut() {
2407            shadow.catalog.set_temp_prefix(prefix.clone());
2408        }
2409    }
2410
2411    /// v7.39 (round 279) — a connection has gone away: drop its parked
2412    /// state and release every advisory lock it still held, which is
2413    /// what PG does at backend exit.
2414    pub fn end_session(&mut self, id: u32) {
2415        // v7.39 (round 436) — a TEMPORARY table dies with its session, in
2416        // both PG and MySQL. Done before the bag is dropped, since the bag
2417        // is what knows which tables the session owns.
2418        let owned: Vec<String> = if id == self.current_session {
2419            self.temp_tables.iter().cloned().collect()
2420        } else {
2421            self.sessions
2422                .get(&id)
2423                .map(|b| b.temp_tables.iter().cloned().collect())
2424                .unwrap_or_default()
2425        };
2426        // v7.39 (round 469) — the same for TEMPORARY sequences and views,
2427        // which PG also drops at backend exit.
2428        let owned_seqs: Vec<String> = if id == self.current_session {
2429            self.temp_sequences.iter().cloned().collect()
2430        } else {
2431            self.sessions
2432                .get(&id)
2433                .map(|b| b.temp_sequences.iter().cloned().collect())
2434                .unwrap_or_default()
2435        };
2436        let owned_views: Vec<String> = if id == self.current_session {
2437            self.temp_views.iter().cloned().collect()
2438        } else {
2439            self.sessions
2440                .get(&id)
2441                .map(|b| b.temp_views.iter().cloned().collect())
2442                .unwrap_or_default()
2443        };
2444        if !owned.is_empty() || !owned_seqs.is_empty() || !owned_views.is_empty() {
2445            let prefix = Self::temp_prefix_for(id);
2446            for logical in owned {
2447                let mangled = alloc::format!("{prefix}{logical}");
2448                self.catalog.drop_table(&mangled);
2449            }
2450            for logical in owned_seqs {
2451                let mangled = alloc::format!("{prefix}{logical}");
2452                self.catalog.drop_sequence(&mangled);
2453            }
2454            for logical in owned_views {
2455                let mangled = alloc::format!("{prefix}{logical}");
2456                self.catalog.drop_view(&mangled);
2457            }
2458            if id == self.current_session {
2459                self.temp_tables.clear();
2460                self.temp_sequences.clear();
2461                self.temp_views.clear();
2462                self.refresh_temp_prefix();
2463            }
2464        }
2465        self.sessions.remove(&id);
2466        self.advisory_locks.retain(|_, (owner, _)| *owner != id);
2467        if id == self.current_session {
2468            self.session_params.clear();
2469            self.prepared_statements.clear();
2470            self.backslash_escapes = false;
2471            self.mysql_strict = true;
2472            self.lo_descriptors.clear();
2473            self.lo_next_fd = 0;
2474            self.cursors.clear();
2475            self.current_session = 0;
2476        }
2477    }
2478
2479    /// v7.39 (round 302, V15) — force the current session's string-literal
2480    /// dialect. A MySQL-protocol connection defaults to MySQL semantics
2481    /// (backslash is an escape: `'\n'` is a newline), which PG's own
2482    /// default (`standard_conforming_strings = on`) does not do. The
2483    /// mysql-wire shim calls this once, right after installing its
2484    /// session, so a client that never sends `SET sql_mode` still gets
2485    /// MySQL string handling; a later `SET sql_mode='NO_BACKSLASH_ESCAPES'`
2486    /// flips it back through the normal SET path. Clearing the plan cache
2487    /// mirrors [`set_current_session`] — the same SQL text lexes
2488    /// differently once the flag moves.
2489    pub fn set_backslash_escapes(&mut self, flag: bool) {
2490        if flag != self.backslash_escapes {
2491            self.backslash_escapes = flag;
2492            self.plan_cache.clear();
2493        }
2494    }
2495
2496    /// v7.39 (round 279) — take an advisory lock. Returns false only
2497    /// when ANOTHER session holds it; re-taking one this session
2498    /// already holds bumps a depth counter, as in PG.
2499    pub(crate) fn advisory_try_lock(&mut self, key: i64) -> bool {
2500        let me = self.current_session;
2501        match self.advisory_locks.get_mut(&key) {
2502            Some((owner, depth)) if *owner == me => {
2503                *depth += 1;
2504                true
2505            }
2506            Some(_) => false,
2507            None => {
2508                self.advisory_locks.insert(key, (me, 1));
2509                true
2510            }
2511        }
2512    }
2513
2514    /// Release one level. False when this session does not hold it —
2515    /// PG answers false and emits a warning; SPG answers false.
2516    pub(crate) fn advisory_unlock(&mut self, key: i64) -> bool {
2517        let me = self.current_session;
2518        match self.advisory_locks.get_mut(&key) {
2519            Some((owner, depth)) if *owner == me => {
2520                *depth -= 1;
2521                if *depth == 0 {
2522                    self.advisory_locks.remove(&key);
2523                }
2524                true
2525            }
2526            _ => false,
2527        }
2528    }
2529
2530    /// Release every advisory lock this session holds.
2531    pub(crate) fn advisory_unlock_all(&mut self) {
2532        let me = self.current_session;
2533        self.advisory_locks.retain(|_, (owner, _)| *owner != me);
2534    }
2535
2536    /// v7.39 (round 417) — the current session's id (for MySQL
2537    /// `IS_USED_LOCK`, which reports the connection that holds a lock).
2538    /// v7.39 (round 430) — read one of this session's MySQL USER
2539    /// variables. `None` when it was never set, which the caller turns
2540    /// into NULL (MariaDB reads an unset user variable as NULL).
2541    pub(crate) fn user_var(&self, name: &str) -> Option<&spg_storage::Value<'static>> {
2542        self.user_vars.get(name)
2543    }
2544
2545    pub(crate) const fn current_session_id(&self) -> u32 {
2546        self.current_session
2547    }
2548
2549    /// v7.39 (round 417) — who holds an advisory-lock key (any session id),
2550    /// or `None` when nobody holds it. Used by MySQL `IS_USED_LOCK` and to
2551    /// separate `RELEASE_LOCK`'s "not held by anyone" (returns NULL) from
2552    /// "held by someone else" (returns 0).
2553    pub(crate) fn advisory_holder(&self, key: i64) -> Option<u32> {
2554        self.advisory_locks.get(&key).map(|(owner, _)| *owner)
2555    }
2556
2557    /// v7.39 (round 417) — MySQL `RELEASE_ALL_LOCKS()` returns the number of
2558    /// locks it released; PG's `pg_advisory_unlock_all()` returns void.
2559    pub(crate) fn advisory_unlock_all_count(&mut self) -> i32 {
2560        let me = self.current_session;
2561        // Total depth held by this session, so re-locked keys count as many.
2562        let mut n: i32 = 0;
2563        for (_, (owner, depth)) in &self.advisory_locks {
2564            if *owner == me {
2565                n = n.saturating_add(*depth as i32);
2566            }
2567        }
2568        self.advisory_locks.retain(|_, (owner, _)| *owner != me);
2569        n
2570    }
2571
2572    #[must_use]
2573    pub const fn with_clock(mut self, clock: ClockFn) -> Self {
2574        self.clock = Some(clock);
2575        self
2576    }
2577
2578    /// Builder: attach an OS-backed RNG for per-user password salts.
2579    /// The host (`spg-server`) typically wires this to `/dev/urandom`.
2580    #[must_use]
2581    pub const fn with_salt_fn(mut self, f: SaltFn) -> Self {
2582        self.salt_fn = Some(f);
2583        self
2584    }
2585
2586    /// v7.38 元机制 D — install a frozen [`testkit::EnvConfig`] snapshot.
2587    ///
2588    /// Hosts (spg-server, spg-embedded, tests) call this once at engine
2589    /// init with either `EnvConfig::from_env()` (production-with-test-vars)
2590    /// or `EnvConfig::builder()....build()` (programmatic). After
2591    /// construction the engine never reads env vars; all test-mode
2592    /// behaviour flows through `self.env_cfg()`.
2593    #[must_use]
2594    pub fn with_env_cfg(mut self, env_cfg: testkit::EnvConfig) -> Self {
2595        // r1051 — a configured seed reaches `random()` too, not only
2596        // the `rng_seed()` accessor: the D-mechanism's "single seed
2597        // source" claim, made true at the SQL level (first pin_v738_
2598        // catch). Production engines carry no seed and never touch
2599        // the PRNG state here.
2600        if let Some(seed) = env_cfg.random_seed {
2601            crate::eval::math::prng_install_seed(seed);
2602        }
2603        // r1058 — `SPG_TEST_FIXED_CLOCK_MICROS` pins the clock for
2604        // hosts that read GUCs from env instead of calling
2605        // `with_clock` (the server). Process-global by necessity: a
2606        // `ClockFn` is a plain fn pointer and cannot capture.
2607        if let Some(micros) = env_cfg.fixed_clock_micros {
2608            FIXED_CLOCK_MICROS.store(micros, core::sync::atomic::Ordering::Relaxed);
2609            self = self.with_clock(fixed_clock_from_env);
2610        }
2611        self.env_cfg = env_cfg;
2612        self
2613    }
2614
2615    /// v7.38 元机制 D — frozen test-mode GUC snapshot. Hot paths gate
2616    /// nondeterministic surfaces on fields of this struct; production
2617    /// default keeps every field at `false / None / Auto` so the
2618    /// optimiser can const-fold the gate.
2619    pub fn env_cfg(&self) -> &testkit::EnvConfig {
2620        &self.env_cfg
2621    }
2622
2623    /// v7.38 元机制 D acceptor — single seed source for every
2624    /// nondeterministic engine subsystem (hash builders, randomised
2625    /// tie-breakers, …). Honour `SPG_TEST_RANDOM_SEED=N` when set;
2626    /// otherwise derive from the engine's wall clock (production) or
2627    /// fall back to a fixed sentinel when the host hasn't installed
2628    /// a clock. Two engines built with the same builder seed return
2629    /// byte-equal output for the same query.
2630    /// See `xtests/sigil/test-mode-gucs.md`.
2631    pub fn rng_seed(&self) -> u64 {
2632        if let Some(seed) = self.env_cfg.random_seed {
2633            return seed;
2634        }
2635        match self.clock {
2636            Some(f) => f() as u64,
2637            // Production engines without a clock installed get a fixed
2638            // non-zero sentinel; same shape as PG's `random()` start
2639            // state under a `setseed(0)`.
2640            None => 0xBAD_5EED_DEAD_BEEF,
2641        }
2642    }
2643
2644    /// v7.38 P0 元机制 A — push this engine's `InjectionStore` onto
2645    /// the thread-local stack so any `injection_point!()` reached
2646    /// during the returned guard's lifetime resolves against this
2647    /// engine. Mirrors PG's per-backend injection table.
2648    ///
2649    /// Returns a no-op guard when the `injection-points` feature is
2650    /// off so call sites don't need `#[cfg]`.
2651    pub fn enter_injection_scope(&self) -> crate::testkit::injection::InjectionGuard {
2652        #[cfg(feature = "injection-points")]
2653        {
2654            crate::testkit::injection::enter_scope(&self.injection_store)
2655        }
2656        #[cfg(not(feature = "injection-points"))]
2657        {
2658            crate::testkit::injection::new_guard()
2659        }
2660    }
2661
2662    /// v7.38 P0 元机制 A — expose the per-engine store so tests can
2663    /// query notice counts / detach actions without parsing SQL
2664    /// output. Only present when the feature is on.
2665    #[cfg(feature = "injection-points")]
2666    pub fn injection_store(&self) -> alloc::sync::Arc<crate::testkit::injection::InjectionStore> {
2667        self.injection_store.clone()
2668    }
2669
2670    /// Builder: cap the number of rows a single SELECT may return.
2671    /// Exceeding the cap raises `EngineError::RowLimitExceeded` —
2672    /// the bound is checked inside the executor so a runaway
2673    /// catalog scan can't allocate millions of rows before the
2674    /// server gets a chance to reject the result.
2675    #[must_use]
2676    pub const fn with_max_query_rows(mut self, n: usize) -> Self {
2677        self.max_query_rows = Some(n);
2678        self
2679    }
2680
2681    /// Builder: cap the approximate heap bytes a single SELECT's
2682    /// join/filter materialisation may hold. Exceeding the cap
2683    /// raises `EngineError::QueryBytesExceeded`. Rows are the wrong
2684    /// unit when one row carries a multi-MB body (mailrs round-26:
2685    /// 1000-row batches of full mail text walked a 15 GiB host into
2686    /// reclaim livelock without ever tripping a row ceiling).
2687    #[must_use]
2688    pub const fn with_max_query_bytes(mut self, n: usize) -> Self {
2689        self.max_query_bytes = Some(n);
2690        self
2691    }
2692
2693    /// The *committed* catalog. Note: during a transaction this returns the
2694    /// pre-TX state — `SELECT` inside a TX goes through `execute()` and reads
2695    /// the shadow. Tests that inspect outside-TX state should use this.
2696    pub const fn catalog(&self) -> &Catalog {
2697        &self.catalog
2698    }
2699
2700    /// Capture a frozen view of the committed engine state. Catalog
2701    /// is O(1) Arc bump; trailers are cheap clones. Decouples "capture"
2702    /// (needs &Engine) from "serialize" (CPU, no engine access) — the
2703    /// seam the background-checkpoint worker rides in CoW-2.
2704    pub fn snapshot_data(&self) -> EngineSnapshot {
2705        EngineSnapshot {
2706            catalog: self.catalog.clone(),
2707            users: self.users.clone(),
2708            publications: self.publications.clone(),
2709            subscriptions: self.subscriptions.clone(),
2710            statistics: self.statistics.clone(),
2711        }
2712    }
2713
2714    /// Serialize the *committed* catalog to bytes. v0.6 was full-snapshot; v0.9
2715    /// adds the rule that an open TX's shadow is never snapshotted — only the
2716    /// post-COMMIT state is persisted. v4.1 wraps the catalog in an envelope
2717    /// when there are users to persist; an empty user table snapshots as the
2718    /// bare catalog format (backwards-compat with v3.x readers). v6.1.2
2719    /// adds publications to the envelope condition: either non-empty
2720    /// users OR non-empty publications now triggers the envelope path.
2721    pub fn snapshot(&self) -> Vec<u8> {
2722        self.snapshot_data().serialize()
2723    }
2724
2725    /// True when at least one TX slot is in flight. v4.41.1 runtime
2726    /// invariant: at most one slot active at a time (dispatch holds
2727    /// `engine.write()` across the entire wrap). v4.42 will let this
2728    /// return true with multiple slots concurrently.
2729    pub fn in_transaction(&self) -> bool {
2730        !self.tx_catalogs.is_empty()
2731    }
2732
2733    /// v7.37 C.5 (A.2) — per-connection in-transaction test. A given
2734    /// connection is "in a transaction" iff its own `tx_id` has an open
2735    /// shadow slot. Unlike [`in_transaction`] (which is true if *any* tx is
2736    /// open), this lets concurrent connections each carry their own explicit
2737    /// transaction without colliding on the global slot. `IMPLICIT_TX` never
2738    /// has a persistent slot (autocommit reads/writes the main catalog), so
2739    /// this is false for the autocommit id.
2740    pub fn is_tx_open(&self, tx_id: TxId) -> bool {
2741        self.tx_catalogs.contains_key(&tx_id)
2742    }
2743
2744    /// v7.37 (round 828) — the user store THIS session should read:
2745    /// its transaction's role shadow when one exists, the committed
2746    /// store otherwise. The auth path and other sessions read
2747    /// `self.users` directly on purpose — an uncommitted role must not
2748    /// be visible to them, let alone able to log in.
2749    pub(crate) fn effective_users(&self) -> &crate::users::UserStore {
2750        if let Some(tx) = self.current_tx
2751            && let Some(state) = self.tx_catalogs.get(&tx)
2752            && let Some(shadow) = &state.users
2753        {
2754            return shadow;
2755        }
2756        &self.users
2757    }
2758
2759    /// v7.37 (round 828) — the store role DDL writes to: the TX's role
2760    /// shadow (created from the committed store on first use) inside a
2761    /// transaction, the committed store in autocommit. Every mutation
2762    /// of roles or memberships goes through here, so `BEGIN; CREATE
2763    /// ROLE r; ROLLBACK` leaves nothing behind — the shadow drops with
2764    /// the TxState — and COMMIT installs the shadow wholesale.
2765    pub(crate) fn role_ddl_users_mut(&mut self) -> &mut crate::users::UserStore {
2766        let tx_slot = self
2767            .current_tx
2768            .filter(|tx| self.tx_catalogs.contains_key(tx));
2769        match tx_slot {
2770            Some(tx) => {
2771                if self
2772                    .tx_catalogs
2773                    .get(&tx)
2774                    .is_some_and(|state| state.users.is_none())
2775                {
2776                    let committed = self.users.clone();
2777                    if let Some(state) = self.tx_catalogs.get_mut(&tx) {
2778                        state.users = Some(committed);
2779                    }
2780                }
2781                self.tx_catalogs
2782                    .get_mut(&tx)
2783                    .and_then(|state| state.users.as_mut())
2784                    .expect("role shadow ensured just above for an open tx slot")
2785            }
2786            None => &mut self.users,
2787        }
2788    }
2789
2790    /// v4.41.1 allocate a fresh TX handle. Used by spg-server dispatch
2791    /// to scope each implicit-wrap BEGIN..stmt..COMMIT to its own slot
2792    /// in `tx_catalogs`. v4.42 — the commit-barrier leader allocates
2793    /// one of these per task in its group, runs `BEGIN`+sql+`COMMIT`
2794    /// sequentially under a single `engine.write()` so each task's
2795    /// mutations accumulate into shared state, then either keeps the
2796    /// accumulated state (fsync OK) or restores the pre-image via
2797    /// `replace_catalog` (fsync err).
2798    pub fn alloc_tx_id(&mut self) -> TxId {
2799        let id = TxId(self.next_tx_id);
2800        self.next_tx_id = self.next_tx_id.saturating_add(1);
2801        id
2802    }
2803
2804    /// v4.42 — atomically replace the live catalog. Used by the
2805    /// commit-barrier leader to roll back a group whose batched
2806    /// fsync failed: the leader snapshots `engine.catalog().clone()`
2807    /// (O(1) Arc bump after the v4.39/v4.40 persistent migration)
2808    /// at group start, sequentially applies each task's BEGIN+sql+
2809    /// COMMIT under the same write lock to accumulate mutations
2810    /// into shared state, batches the WAL bytes, fsyncs once, and
2811    /// on failure calls this with the pre-image to undo every
2812    /// task in the group at once.
2813    ///
2814    /// **Does NOT touch `tx_catalogs` / `current_tx`.** Any
2815    /// explicit-TX slot from a concurrent client (created via the
2816    /// legacy `IMPLICIT_TX`-less dispatch path or via the future
2817    /// MVCC-readers v5+ work) has its own snapshot baked into the
2818    /// slot — restoring `self.catalog` to the pre-image leaves
2819    /// those slots untouched, exactly as they were when the leader
2820    /// took the lock. The leader's own implicit-TX slots are all
2821    /// already discarded (`exec_commit` removed them as each
2822    /// task's COMMIT ran) by the time this is reached.
2823    pub fn replace_catalog(&mut self, catalog: Catalog) {
2824        self.catalog = catalog;
2825    }
2826
2827    /// v6.7.0 — public shim around `Catalog::freeze_oldest_to_cold`
2828    /// so tests + the spg-server freezer can drive a freeze without
2829    /// reaching into the private `active_catalog_mut`. v6.7.4
2830    /// parallel freezer will build on this surface.
2831    ///
2832    /// Marks the table's cached `cold_row_count` stale because the
2833    /// freeze added cold locators that ANALYZE hasn't yet refreshed.
2834    pub fn freeze_oldest_to_cold(
2835        &mut self,
2836        table_name: &str,
2837        index_name: &str,
2838        max_rows: usize,
2839    ) -> Result<spg_storage::FreezeReport, EngineError> {
2840        let report = self
2841            .active_catalog_mut()
2842            .freeze_oldest_to_cold(table_name, index_name, max_rows)
2843            .map_err(EngineError::Storage)?;
2844        if let Some(t) = self.active_catalog_mut().get_mut(table_name) {
2845            t.mark_cold_row_count_stale();
2846        }
2847        Ok(report)
2848    }
2849
2850    /// v6.7.5 — public shim used by the spg-server follower's
2851    /// segment-forwarding receiver. Registers a cold-tier segment
2852    /// at a specific id (the master's id, as transmitted on the
2853    /// wire) so the follower's BTree-Cold locators stay byte-
2854    /// identical with the master's. Wraps
2855    /// `Catalog::load_segment_bytes_at` under the standard
2856    /// clone-mutate-replace pattern.
2857    ///
2858    /// Returns `Ok(())` on success **and** on the "slot already
2859    /// occupied" case — a follower mid-reconnect may receive a
2860    /// segment chunk for a segment_id it already has on disk
2861    /// (forwarded last session); the caller should treat that
2862    /// path as a no-op rather than a fatal error.
2863    pub fn receive_cold_segment(
2864        &mut self,
2865        segment_id: u32,
2866        bytes: Vec<u8>,
2867    ) -> Result<(), EngineError> {
2868        let mut new_cat = self.catalog.clone();
2869        match new_cat.load_segment_bytes_at(segment_id, bytes) {
2870            Ok(()) => {
2871                self.replace_catalog(new_cat);
2872                Ok(())
2873            }
2874            Err(StorageError::Corrupt(msg)) if msg.contains("already occupied") => Ok(()),
2875            Err(e) => Err(EngineError::Storage(e)),
2876        }
2877    }
2878
2879    /// v7.39 (round 598) — mutable access to the base catalog, for the
2880    /// recursive-CTE loop.
2881    ///
2882    /// It built a whole `Engine` per iteration to hold the working set:
2883    /// `Engine::restore` initialises 82 fields, and a counting allocator put
2884    /// the loop at 63 allocations and 104 kB PER ITERATION — 1 GB for a
2885    /// 10,000-row recursive CTE, none of it dependent on how much else was
2886    /// in the catalog. One engine, whose CTE table is refilled each round,
2887    /// needs this.
2888    pub(crate) fn base_catalog_mut(&mut self) -> &mut Catalog {
2889        &mut self.catalog
2890    }
2891
2892    pub(crate) fn active_catalog(&self) -> &Catalog {
2893        match self.current_tx {
2894            Some(t) => self
2895                .tx_catalogs
2896                .get(&t)
2897                .map_or(&self.catalog, |s| &s.catalog),
2898            None => &self.catalog,
2899        }
2900    }
2901
2902    fn active_catalog_mut(&mut self) -> &mut Catalog {
2903        let tx = self.current_tx;
2904        match tx {
2905            Some(t) => match self.tx_catalogs.get_mut(&t) {
2906                Some(s) => {
2907                    // v7.39 (round 494) — see `TxState::shadow_dirty`.
2908                    s.shadow_dirty = true;
2909                    &mut s.catalog
2910                }
2911                None => &mut self.catalog,
2912            },
2913            None => &mut self.catalog,
2914        }
2915    }
2916
2917    /// v7.34 (crash-recovery P0 #2) — turn row-level redo capture on/off.
2918    /// The embedding layer enables it when persistence is on so each
2919    /// mutating `execute` records the physical [`RowChange`]s it applied
2920    /// (drained via [`Engine::take_redo`]). Off = zero capture overhead.
2921    pub fn set_redo_capture(&mut self, on: bool) {
2922        self.redo_capture = on;
2923    }
2924
2925    /// v7.39 (round 735, S14/B3) — record that `table`'s rows (or shape)
2926    /// changed. Cheap (one BTreeMap bump), called from every write entry;
2927    /// the materialized-view refresh watermark reads it.
2928    /// v7.39 (round 736) — per-view buffered-delta ceiling. Past this,
2929    /// the view's next REFRESH is a full one (the buffer is the
2930    /// optimisation, not the truth).
2931    pub(crate) const MATVIEW_DELTA_CEILING: usize = 65_536;
2932
2933    /// v7.39 (round 736) — fan the drained redo out to every
2934    /// maintainable view whose base table it touches.
2935    pub(crate) fn fan_out_matview_deltas(&mut self, drained: &[RowChange]) {
2936        if self.matview_maintainable.is_empty() {
2937            return;
2938        }
2939        for ch in drained {
2940            let t = ch.table_name().to_ascii_lowercase();
2941            let hit: Vec<String> = self
2942                .matview_maintainable
2943                .iter()
2944                .filter(|(_, base)| **base == t)
2945                .map(|(mv, _)| mv.clone())
2946                .collect();
2947            for mv in hit {
2948                if self.matview_delta_overflow.contains(&mv) {
2949                    continue;
2950                }
2951                let buf = self.matview_delta_buf.entry(mv.clone()).or_default();
2952                if buf.len() >= Self::MATVIEW_DELTA_CEILING {
2953                    self.matview_delta_overflow.insert(mv.clone());
2954                    self.matview_delta_buf.remove(&mv);
2955                } else {
2956                    buf.push(ch.clone());
2957                    MATVIEW_FANOUT_BUFFERED.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
2958                }
2959            }
2960        }
2961    }
2962
2963    pub(crate) fn bump_table_change(&mut self, table: &str) {
2964        let k = table.to_ascii_lowercase();
2965        *self.table_change_seq.entry(k).or_insert(0) += 1;
2966    }
2967
2968    /// v7.37.8 — read accessor for tests / observability. The
2969    /// embedding layer flips this on once per `open_path` (after
2970    /// replay completes) when `SPG_WAL_ROW_REDO` is enabled (now
2971    /// default in v7.37.8). A consumer that wants to verify the
2972    /// post-upgrade contract ("writes go to V5 ROW_REDO by default")
2973    /// reads this through `Database::engine_redo_capture()` instead
2974    /// of inspecting WAL bytes (which the auto-checkpoint truncates
2975    /// on `Drop`).
2976    pub fn redo_capture_enabled(&self) -> bool {
2977        self.redo_capture
2978    }
2979
2980    /// v7.38 轴 4 — currently-selected SQL isolation level. Default
2981    /// `ReadCommitted` after construction; updated by
2982    /// `SET TRANSACTION ISOLATION LEVEL …`. Read by
2983    /// `SHOW transaction_isolation` and any future MVCC/SSI gate.
2984    pub fn current_isolation_level(&self) -> spg_sql::ast::IsolationLevel {
2985        self.current_isolation_level
2986    }
2987
2988    /// v7.34 — take the redo captured by the most recent successful
2989    /// mutating `execute` (empty when capture is off, the statement was a
2990    /// read, or it changed nothing). The embedding layer writes these to
2991    /// the WAL in place of the SQL text.
2992    pub fn take_redo(&mut self) -> Vec<RowChange> {
2993        core::mem::take(&mut self.last_redo)
2994    }
2995
2996    /// v7.34 (crash-recovery P0 #2) — replay a row-level redo log onto the
2997    /// committed catalog (the row-level WAL recovery primitive: apply the
2998    /// captured physical changes from a checkpoint baseline, in place of
2999    /// re-executing the SQL). Trusts the log — no uniqueness/FK/parse.
3000    pub fn apply_redo(&mut self, changes: &[RowChange]) -> Result<(), EngineError> {
3001        self.catalog
3002            .apply_redo(changes)
3003            .map_err(EngineError::Storage)
3004    }
3005
3006    /// Read-only execute path. Succeeds for `SELECT` / `SHOW TABLES`
3007    /// / `SHOW COLUMNS`; returns `EngineError::WriteRequired` for
3008    /// every other statement, so the caller can fall through to the
3009    /// `&mut self` `execute` path under a write lock. Engine state is
3010    /// not mutated even on the success path (`rewrite_clock_calls`
3011    /// and `resolve_order_by_position` both mutate the locally-owned
3012    /// AST, not `self`).
3013    ///
3014    /// v4.2: cap result-set size. Applied after the executor
3015    /// materialises rows but before they leave the engine — wrapping
3016    /// every Rows-returning exec_* function would scatter the check.
3017    ///
3018    /// v7.31 (memory campaign, bucket A) — the same choke point now
3019    /// also enforces the BYTE budget on the final result set, so
3020    /// single-table and aggregate paths (which don't route through
3021    /// the join materialiser's incremental accounting) still cannot
3022    /// hand the host an unbounded result. Intermediate single-table
3023    /// clones are the 7.31.x follow-up (design doc, bucket A).
3024    fn enforce_row_limit(
3025        &self,
3026        result: Result<QueryResult, EngineError>,
3027    ) -> Result<QueryResult, EngineError> {
3028        if let Ok(QueryResult::Rows { rows, .. }) = &result {
3029            if let Some(cap) = self.max_query_rows
3030                && rows.len() > cap
3031            {
3032                return Err(EngineError::RowLimitExceeded(cap));
3033            }
3034            if let Some(byte_cap) = self.max_query_bytes
3035                && approx_rows_bytes(rows) > byte_cap
3036            {
3037                return Err(EngineError::QueryBytesExceeded(byte_cap));
3038            }
3039        }
3040        result
3041    }
3042}
3043
3044/// v7.31 (memory campaign — ceiling-first / never-die, design v1) —
3045/// per-table slice of the engine's resident-memory accounting.
3046/// `hot_encoded_bytes` is the storage layer's maintained meter (what
3047/// the rows encode to); `approx_resident_bytes` is what they COST in
3048/// RAM (per-cell enum slots + heap payloads via `approx_row_bytes`)
3049/// — the gap between the two is the representation multiplier the
3050/// round-26 report measured at ~11× end-to-end.
3051#[derive(Debug, Clone)]
3052pub struct TableMemoryStats {
3053    pub name: String,
3054    pub hot_rows: u64,
3055    /// Cached cold-row count (refreshed by ANALYZE — see
3056    /// `Table::cold_row_count`'s staleness contract).
3057    pub cold_rows: u64,
3058    pub hot_encoded_bytes: u64,
3059    pub approx_resident_bytes: u64,
3060    pub index_count: u64,
3061    /// v7.31 C2 — sum of `IndexKind::approx_resident_bytes()` over the
3062    /// table's indices: every variant (BTree / NSW / BRIN / GIN family)
3063    /// walks its own structure, so the GIN posting lists and NSW layer
3064    /// adjacency that dominate text/vector tables are counted honestly
3065    /// instead of the old flat-token estimate.
3066    pub approx_index_bytes: u64,
3067}
3068
3069/// v7.31 — whole-engine memory snapshot: the polling form of the
3070/// round-26 ask-4 watermark signal. Hosts compare
3071/// `total_approx_resident_bytes` (+ their own WAL/file accounting)
3072/// against their deployment ceiling and shed/shrink before the
3073/// kernel does it for them.
3074#[derive(Debug, Clone)]
3075pub struct MemoryStats {
3076    pub tables: Vec<TableMemoryStats>,
3077    pub total_hot_encoded_bytes: u64,
3078    pub total_approx_resident_bytes: u64,
3079    pub total_approx_index_bytes: u64,
3080    /// The active per-query materialisation budget (bucket A), so a
3081    /// monitoring host sees ceiling and usage through one call.
3082    pub max_query_bytes: Option<usize>,
3083    /// v7.31 C2 — bucket D: live WAL bytes (active chunk + buffered,
3084    /// uncheckpointed). `None` from the engine itself — it has no WAL;
3085    /// the durable hosts (embed `Database`, server) fill it in from
3086    /// their own WAL accounting. `Some(0)` means "host has a WAL and
3087    /// it is empty"; `None` means "no WAL on this path" (in-memory).
3088    pub wal_bytes: Option<u64>,
3089}
3090
3091/// v6.2.0 — true for engine-managed catalog tables that the bare
3092/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
3093/// tables yet (publications / subscriptions / users / statistics
3094/// all live as engine fields, not catalog tables), so this is a
3095/// reserved future-proofing hook — every existing user table is
3096/// analysed.
3097const fn is_internal_table_name(_name: &str) -> bool {
3098    false
3099}
3100
3101#[cfg(test)]
3102mod tests;