Skip to main content

spg_embedded/
lib.rs

1// v7.7.2 — every public item in this crate must carry a
2// doc-comment; new code that adds a `pub` without one fails CI.
3#![deny(missing_docs)]
4
5//! # spg-embedded
6//!
7//! Ergonomic embedded-mode entry point for SPG. Wraps the
8//! `spg-engine` execution layer for in-process applications
9//! that don't want to spin up a TCP listener / fork to the
10//! `spg-server` binary.
11//!
12//! ## Quick start
13//!
14//! ```no_run
15//! use spg_embedded::Database;
16//!
17//! // On-disk, durable. WAL fsynced per commit; auto-checkpoint
18//! // at 4 MiB WAL by default.
19//! let mut db = Database::open_path("/data/app.db").unwrap();
20//! db.execute("CREATE TABLE users (id INT NOT NULL, name TEXT)").unwrap();
21//! db.execute("INSERT INTO users VALUES (1, 'alice')").unwrap();
22//! let rows = db.query("SELECT name FROM users WHERE id = 1").unwrap();
23//! for row in &rows {
24//!     println!("{:?}", row);
25//! }
26//! ```
27//!
28//! ## Production checklist (v7.5)
29//!
30//! - **Persistence**: `Database::open_path(p)` writes a
31//!   crash-consistent WAL + periodic checkpoint snapshot. The
32//!   on-disk format is byte-identical to what `spg-server`
33//!   produces, so a database can move between modes without
34//!   conversion.
35//! - **Durability**: every `execute()` that mutates calls
36//!   `fsync` before returning `Ok`. There is no group commit
37//!   in embedded mode — every commit pays one fsync. If you
38//!   need batch throughput, wrap multiple statements in
39//!   [`Database::with_transaction`] which fsyncs only at
40//!   commit.
41//! - **Concurrency**: [`Database`] is `Send` but **not** `Sync`.
42//!   Share across threads via `Arc<Mutex<Database>>`. The
43//!   single-writer model is intentional — see
44//!   [STABILITY § A1](https://github.com/lihao/spg/blob/master/STABILITY.md).
45//! - **Background work**: [`Database::spawn_background_freezer`]
46//!   moves cold rows to disk-resident segments while you keep
47//!   serving requests. It runs in a dedicated thread; drop the
48//!   returned [`FreezerHandle`] (or call `stop()`) for clean
49//!   shutdown.
50//! - **Errors**: all public enums ([`EngineError`],
51//!   [`QueryResult`], [`Value`]) are `#[non_exhaustive]`. Match
52//!   them with a wildcard arm so future v7.x releases can add
53//!   variants without breaking your code.
54//!
55//! ## Panic contract
56//!
57//! - **No `execute()` / `query()` call panics on user input.**
58//!   Malformed SQL, type mismatches, missing tables — all
59//!   return `Err(EngineError::…)`. If you observe a panic on
60//!   a user-controlled string, that is a bug; file an issue.
61//! - The library panics **only** on internal invariant
62//!   violations (e.g., catalog snapshot magic mismatch, WAL
63//!   record CRC sentinel corruption that survived the boot-
64//!   time validation). These represent silent disk corruption
65//!   and an unwind would leak inconsistent state, so the
66//!   release profile uses `panic = abort` — your host process
67//!   dies fast rather than continuing on poisoned data.
68//! - If you cannot tolerate `panic = abort`, build with
69//!   `--profile release-dbg` (keeps unwind tables) and use
70//!   `std::panic::catch_unwind` at your application boundary.
71//!
72//! ## Why a separate crate?
73//!
74//! `spg-engine` is `no_std`-compatible (vendored alloc-only).
75//! The embedded-mode entry point uses `std` (filesystem,
76//! threading), so it lives in its own crate to keep the
77//! `no_std` boundary clean.
78
79pub use spg_engine::{CatalogSnapshot, Engine, EngineError, ParsedStatement, QueryResult};
80// v7.38 P0 元机制 A — re-export the macro so downstream crates that
81// only depend on spg-embedded (e.g. spg-sqlx) can fire injection
82// points without pulling in spg-engine directly.
83pub use spg_engine::injection_point;
84pub use spg_storage::{ColumnSchema, DataType, Value, ValueOwned};
85
86/// v7.16.0 — handle for a parsed-and-planned SQL statement.
87/// Hand off to [`Database::execute_prepared`] / [`Database::query_prepared`]
88/// with a `&[Value]` slice carrying the bind parameters (PG-style
89/// `$1`, `$2`, … positional). Cheap to `Clone`; the underlying AST
90/// is shared by handle copies and cloned per bind call by the
91/// engine's executor.
92///
93/// The handle holds a snapshot of the AST at prepare time. If
94/// the engine's plan cache evicts the entry between prepare and
95/// execute (e.g. ANALYZE bumps the statistics version) the
96/// stored AST keeps working — `execute_prepared` operates on
97/// the handle's clone, not the cache entry.
98#[derive(Debug, Clone)]
99pub struct Statement {
100    /// The parsed + planned AST. `spg-engine::prepare_cached`
101    /// returns it as a clone of the cached plan, so any rewrite
102    /// passes (`expand_group_by_all`, `reorder_joins`, …) have
103    /// already run.
104    pub(crate) stmt: ParsedStatement,
105    /// Original SQL source, kept for `Display` / debug only.
106    /// WAL persistence renders from the AST so a bind-time
107    /// rewrite of `$1..$N` survives replay.
108    pub(crate) sql: String,
109}
110
111impl Statement {
112    /// Borrow the original SQL source — useful for tracing and
113    /// debug logs. WAL replay does NOT use this; it serialises
114    /// the bind-final AST instead.
115    #[must_use]
116    pub fn sql(&self) -> &str {
117        &self.sql
118    }
119}
120
121/// v7.16.0 — internal WAL helper. Mirrors what
122/// `Engine::execute_prepared` does to the cloned AST so the WAL
123/// record carries the bind-final SQL text (so replay's
124/// simple-query path reconstructs the same row state without
125/// needing the original `Statement` handle to still be alive).
126/// Errors from the underlying engine helper would only fire if
127/// the bind-final stmt referenced a placeholder past the params
128/// slice — and that case has already errored in the executor
129/// above before this helper runs, so we discard the Result here.
130fn wal_render_with_params(stmt: &mut ParsedStatement, params: &[Value<'static>]) {
131    let _ = spg_engine::substitute_placeholders(stmt, params);
132}
133
134use std::collections::BTreeMap;
135use std::fs::{File, OpenOptions};
136use std::io::Write;
137use std::path::{Path, PathBuf};
138use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
139use std::sync::{Arc, Condvar, Mutex};
140use std::thread::{self, JoinHandle};
141use std::time::{Duration, SystemTime, UNIX_EPOCH};
142
143/// v7.11.3 — wall-clock provider injected into every embedded
144/// `Engine`. Microseconds since the Unix epoch; clamps to
145/// `i64::MAX` if the system clock is far-future. Used by SQL's
146/// `NOW()` / `CURRENT_TIMESTAMP` / `CURRENT_DATE` rewrite layer
147/// so PG-idiomatic time queries work without the caller wiring
148/// their own clock.
149/// v7.36 (mailrs ask #4) — flatten an `EXPLAIN` QueryResult into
150/// the QUERY PLAN string lines. `EXPLAIN` always returns a single-
151/// column TEXT table; anything else is treated as no plan output.
152fn extract_query_plan_lines(result: QueryResult) -> Vec<String> {
153    match result {
154        QueryResult::Rows { rows, .. } => rows
155            .into_iter()
156            .filter_map(|r| {
157                r.values.into_iter().next().and_then(|v| match v {
158                    Value::Text(s) => Some(s.into_owned()),
159                    _ => None,
160                })
161            })
162            .collect(),
163        _ => Vec::new(),
164    }
165}
166
167/// v7.37.2 — auto-warm the OS page cache for cold-tier segments at
168/// `open_path` / `restore` time. Per the zero-customer-change rule
169/// the client never calls `warm_up_cold_tier()` from app code; the
170/// catalog is server-ready when its constructor returns.
171///
172/// Budget controls:
173/// * `SPG_WARM_UP_COLD_BUDGET_MS=N` — stop warming after N ms
174///   wall-clock (best-effort; granularity is per-table). Unset =
175///   no cap.
176/// * `SPG_WARM_UP_COLD_BUDGET_MS=0` — skip warm-up entirely (escape
177///   hatch for ops that need fast restart even at the cost of the
178///   first-query cold spike).
179fn autowarm_cold_tier_on_open(db: &Database) {
180    let budget_ms = std::env::var("SPG_WARM_UP_COLD_BUDGET_MS")
181        .ok()
182        .and_then(|s| s.parse::<u64>().ok());
183    if let Some(0) = budget_ms {
184        return;
185    }
186    // v7.38 P0 元机制 A — cold-tier wakeup boundary. Tests use this to
187    // inject a wait that fires after open_path's commit but before the
188    // first warm-up pass: simulates "concurrent client hits cold pages
189    // while warm-up is still doing its initial scan". Pairs with
190    // `checkpoint_cow_swap_post` for full crash-recovery race
191    // coverage.
192    spg_engine::injection_point!("cold_tier_wakeup_resume", &budget_ms);
193    let start = std::time::Instant::now();
194    let touched = db.warm_up_cold_tier();
195    let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
196    let over_budget = budget_ms.is_some_and(|b| elapsed_ms > b);
197    let _ = (touched, over_budget); // future: tracing::info
198}
199
200fn wall_clock_micros() -> i64 {
201    SystemTime::now()
202        .duration_since(UNIX_EPOCH)
203        .map_or(0, |d| i64::try_from(d.as_micros()).unwrap_or(i64::MAX))
204}
205
206use spg_manifest::{CatalogManifest, ColdSegmentEntry, manifest_path as spg_manifest_path};
207
208// -- v7.1 WAL format constants (mirror `spg-server`'s) ---------
209// Kept private so callers can't mis-frame records; the v3 layout
210// is the same the server uses, so a `spg-server` boot can read a
211// database an embedded process wrote and vice versa.
212const WAL_V2_SENTINEL: u32 = 0x8000_0000;
213const WAL_V3_FLAG: u32 = 0x4000_0000;
214const WAL_V3_TYPE_AUTO_COMMIT_SQL: u8 = 0x01;
215/// v7.18 — durability checkpoint marker stays at 0x02 (skipped on replay).
216const WAL_V3_TYPE_DURABILITY_CHECKPOINT: u8 = 0x02;
217/// v7.18 PITR — auto-commit-sql record with appended (commit_lsn,
218/// commit_unix_us) fields so replay can target a specific point in
219/// time. Backward-compat: v3 records (type 0x01) keep working, the
220/// envelope flag bits are unchanged. The new type byte is the
221/// schema-version discriminator.
222const WAL_V4_TYPE_AUTO_COMMIT_SQL: u8 = 0x10;
223/// v7.18 — sentinel for "no wall clock" inside a v4 record's
224/// commit_unix_us slot. Restore-to-timestamp skips records with
225/// this sentinel (no time anchor); LSN-based restore is
226/// unaffected.
227const WAL_V4_NO_CLOCK: i64 = i64::MIN;
228/// v7.18 — extra header bytes after the type byte in a v4 record:
229/// 8 bytes commit_lsn (u64 LE) + 8 bytes commit_unix_us (i64 LE).
230const WAL_V4_EXTRA_HEADER: usize = 16;
231/// v7.18 PITR — checkpoint anchor record written to the WAL *before*
232/// the snapshot file replaces the on-disk catalog. Carries the
233/// (lsn, ts, snapshot_path) triple so restore tooling can find the
234/// matching base snapshot without scanning the filesystem. Replay
235/// dispatch skips it (same as the v3 durability marker).
236const WAL_V4_TYPE_CHECKPOINT_MARKER: u8 = 0x11;
237
238/// v7.21 (mailrs embed round-12 polish) — one COMMITted explicit
239/// transaction, flushed atomically at COMMIT time. Payload = the
240/// transaction's bind-final mutation statements joined with `";\n"`;
241/// replay re-splits via [`split_statements`] and applies in order.
242/// Same 16-byte (commit_lsn, commit_unix_us) prefix as the v4
243/// auto-commit record. The record is CRC-framed like every other
244/// record, so replay applies the whole transaction or — torn tail —
245/// none of it; a transaction can never half-resurrect.
246///
247/// Why it exists: in-transaction mutations only touch the engine's
248/// shadow catalog (`modified_catalog: false`), so the per-statement
249/// auto-commit append never fired and a COMMIT followed by a crash
250/// (no graceful Drop checkpoint) lost the transaction.
251const WAL_V4_TYPE_TX_COMMIT_SQL: u8 = 0x12;
252
253/// v7.34 (crash-recovery P0 #2) — row-level physical redo record. Same v4
254/// envelope (lsn + ts + payload + CRC) but the payload is `encode_redo_log`
255/// bytes, not SQL. Replay applies the physical [`RowChange`]s via
256/// `Engine::apply_redo` instead of re-executing — O(changed rows), not the
257/// O(records × catalog_rows) statement-replay that hung the mailrs P0.
258const WAL_V5_TYPE_ROW_REDO: u8 = 0x13;
259
260// v7.37.13 (A1.2 / A1.3 / A1.6) — WAL record format v6.
261//
262// Differences vs v5:
263//   - CRC slot holds CRC-32C (Castagnoli, PG-equivalent since 9.3)
264//     instead of IEEE CRC-32. Better Hamming distance on long
265//     records; modern CPUs have a hardware instruction
266//     (SSE4.2 / ARMv8) — software fallback for now.            [A1.2]
267//   - 8-byte `prev_lsn` field (xl_prev equivalent) detects a torn
268//     chunk where two adjacent records' LSNs aren't contiguous.
269//     v7.37.13.5 always writes 0; v7.37.13.7 populates it.       [A1.3]
270//   - 1-byte `hash_scheme`. Default 0 (CRC32C only). 1 means a
271//     32-byte BLAKE3 of the payload follows. Opt-in via
272//     `SPG_WAL_HASH=blake3` for operators who want cryptographic
273//     integrity of the WAL bytes on top of bit-flip protection. [A1.6]
274//   - Type bytes are REUSED from v5 (0x10..=0x13). The v6 envelope
275//     is distinguished from v5 strictly by the V6_FLAG bit in the
276//     length header, so the parser stays one branch deep.
277//
278// On-disk layout of a v6 record:
279//   [4B u32 LE  payload_len | V2_SENTINEL | V3_FLAG | V6_FLAG]
280//   [4B u32 LE  crc32c]                                          // of body below
281//   [1B        type_byte]
282//   [8B u64 LE prev_lsn]
283//   [8B u64 LE commit_lsn]
284//   [8B i64 LE commit_unix_us]
285//   [1B        hash_scheme]
286//   [32B       blake3]                if hash_scheme == 1
287//   [payload]
288//
289// Backward compat: v3 / v4 / v5 records still parse via the
290// existing branches; v6 is only chosen for NEW writes.
291const WAL_V6_FLAG: u32 = 0x2000_0000;
292const WAL_V6_EXTRA_HEADER: usize = 8 /*prev_lsn*/ + 8 /*commit_lsn*/ + 8 /*commit_unix_us*/ + 1 /*hash_scheme*/;
293const WAL_V6_HASH_SCHEME_CRC32C: u8 = 0;
294const WAL_V6_HASH_SCHEME_BLAKE3: u8 = 1;
295const WAL_V6_BLAKE3_LEN: usize = 32;
296
297/// v7.1 — auto-checkpoint threshold. Once the WAL grows past
298/// this many bytes, the next successful `execute()` call ends
299/// with a `checkpoint()` so the WAL stays bounded. Tunable via
300/// `SPG_EMBEDDED_CHECKPOINT_BYTES` env.
301/// v7.37.8 — **default ON**. v7.34 introduced row-level redo (0x13
302/// records, replayed via `apply_redo` in O(changed rows) instead of
303/// re-executing SQL in O(records × catalog_rows)). It shipped opt-in
304/// (`SPG_WAL_ROW_REDO=1`) "during bringup", but the only meaningful
305/// prod consumer (mailrs) never had a path to set the env var (per
306/// the dogfood "zero mailrs change" contract). The result was 4
307/// recurrences of crash-recovery lock-hang between v7.37.5 and
308/// v7.37.7 — every restart paid the V4 SQL replay tax. v7.37.8
309/// flips the default ON so an `spg-X.Y.Z` upgrade alone delivers
310/// the fix; `SPG_WAL_ROW_REDO=0` remains available as an explicit
311/// operator opt-out for any caller that needs the legacy V4 SQL
312/// path (e.g. for forensics / downgrade prep). DDL still logs as
313/// SQL (hybrid log) on both sides. When this returns true,
314/// `open_path` arms the engine's redo capture.
315fn row_redo_enabled() -> bool {
316    match std::env::var("SPG_WAL_ROW_REDO").ok() {
317        Some(v) if v == "0" || v.eq_ignore_ascii_case("false") => false,
318        Some(_) | None => true,
319    }
320}
321
322fn default_checkpoint_threshold_bytes() -> u64 {
323    std::env::var("SPG_EMBEDDED_CHECKPOINT_BYTES")
324        .ok()
325        .and_then(|s| s.parse::<u64>().ok())
326        .filter(|&n| n > 0)
327        .unwrap_or(4 * 1024 * 1024)
328}
329
330/// v7.37.10 — time-based auto-checkpoint interval (seconds).
331/// Default 60 s — bounds data-loss-on-quarantine-WAL to ~1 minute of
332/// writes. `SPG_EMBEDDED_CHECKPOINT_SECONDS=0` disables the timer
333/// (byte-threshold path remains active); negative or invalid values
334/// fall back to the default.
335fn default_checkpoint_time_threshold() -> Option<core::time::Duration> {
336    match std::env::var("SPG_EMBEDDED_CHECKPOINT_SECONDS")
337        .ok()
338        .and_then(|s| s.parse::<u64>().ok())
339    {
340        Some(0) => None,
341        Some(n) => Some(core::time::Duration::from_secs(n)),
342        None => Some(core::time::Duration::from_secs(60)),
343    }
344}
345
346/// v7.30.3 (mailrs round-26) — per-query byte budget on join/filter
347/// materialisation, default ON at 256 MiB for embed parity with the
348/// server's allocator-level `SPG_MAX_QUERY_BYTES` default. A fat
349/// backfill batch (1000 × full mail bodies) then errors with
350/// `QueryBytesExceeded` instead of walking the host into reclaim
351/// livelock. `SPG_MAX_QUERY_BYTES=0` disables; any other value
352/// overrides. NOT applied to the WAL-replay engine — replay must
353/// never fail on a tuning knob.
354fn engine_with_query_byte_budget(engine: Engine) -> Engine {
355    const DEFAULT_MAX_QUERY_BYTES: usize = 256 * 1024 * 1024;
356    let mut engine = match std::env::var("SPG_MAX_QUERY_BYTES")
357        .ok()
358        .and_then(|s| s.trim().parse::<usize>().ok())
359    {
360        Some(0) => engine,
361        Some(n) => engine.with_max_query_bytes(n),
362        None => engine.with_max_query_bytes(DEFAULT_MAX_QUERY_BYTES),
363    };
364    // v7.37.16 — the in-place MVCC write path defaults ON; the env is
365    // now a two-way override (the no_std engine can't read it itself):
366    // `SPG_MVCC_INPLACE=0|false|off` reverts to the legacy physical
367    // delete, `=1|true|on` forces on (redundant but harmless).
368    if let Some(on) = mvcc_inplace_env() {
369        engine.set_mvcc_inplace(on)
370    }
371    // v7.37.16 — autovacuum defaults ON; `SPG_AUTOVACUUM=0|false|off`
372    // disables (operators running their own vacuum cadence).
373    if std::env::var("SPG_AUTOVACUUM")
374        .is_ok_and(|v| v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off"))
375    {
376        engine.set_autovacuum(false);
377    }
378    // v7.39 — parallel aggregation defaults ON in embedded-std too
379    // (`SPG_PARALLEL=0|false|off` opts out); pure-no_std embeddings
380    // never see this file and stay single-threaded.
381    if !std::env::var("SPG_PARALLEL")
382        .is_ok_and(|v| v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off"))
383    {
384        engine.set_parallel_runner(std::sync::Arc::new(ScopedThreadRunner));
385    }
386    // v7.39 (tz epic) — named-timezone lookups via the system zoneinfo.
387    engine.set_tz_fns(
388        spg_tzif::tz_offset_at,
389        spg_tzif::tz_local_to_utc,
390        spg_tzif::tz_canonical,
391        spg_tzif::tz_abbrev_at,
392    );
393    // v7.39 (round 502) — and the enumerator behind pg_timezone_names.
394    engine.set_tz_all_fn(spg_tzif::tz_all_at);
395    engine
396}
397
398/// `SPG_MVCC_INPLACE` — two-way override for the in-place MVCC write
399/// path (default ON since v7.37.16). `0|false|off` → legacy physical
400/// delete; `1|true|on` → force on; unset/other → engine default.
401fn mvcc_inplace_env() -> Option<bool> {
402    let v = std::env::var("SPG_MVCC_INPLACE").ok()?;
403    if v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off") {
404        Some(false)
405    } else if v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("on") {
406        Some(true)
407    } else {
408        None
409    }
410}
411
412/// v7.1 — encode one v3 `auto_commit_sql` record. Layout:
413///
414/// ```text
415/// [u32 LE (len | WAL_V2_SENTINEL | WAL_V3_FLAG)]
416/// [u32 LE crc32 over (type_byte || sql_bytes)]
417/// [u8 type = 0x01]
418/// [sql bytes]
419/// ```
420fn encode_v3_auto_commit(sql: &str) -> Vec<u8> {
421    let payload = sql.as_bytes();
422    let mut crc_buf = Vec::with_capacity(1 + payload.len());
423    crc_buf.push(WAL_V3_TYPE_AUTO_COMMIT_SQL);
424    crc_buf.extend_from_slice(payload);
425    let crc = spg_crypto::crc32::crc32(&crc_buf);
426    let header = ((payload.len() as u32) | WAL_V2_SENTINEL | WAL_V3_FLAG).to_le_bytes();
427    let mut out = Vec::with_capacity(4 + 4 + 1 + payload.len());
428    out.extend_from_slice(&header);
429    out.extend_from_slice(&crc.to_le_bytes());
430    out.push(WAL_V3_TYPE_AUTO_COMMIT_SQL);
431    out.extend_from_slice(payload);
432    out
433}
434
435/// v7.20 P2 — WAL group-commit. N concurrent commits share one
436/// fsync (the 4.2 ms p50 that profile_breakdown measured as
437/// 99.2% of the durable write path).
438///
439/// Leader-follower protocol, same family as PG's group commit:
440///
441/// 1. `enqueue(record)` — called while the caller still holds
442///    the engine's write lock. Appends the encoded record to the
443///    shared buffer, returns a sequence ticket. O(memcpy).
444/// 2. Caller RELEASES the engine write lock (the next writer's
445///    mutation proceeds in parallel with this batch's fsync).
446/// 3. `wait_flushed(seq)` — if nobody is flushing, the caller
447///    elects itself leader: swaps the buffer out, writes +
448///    fsyncs ONCE for every record in the batch, marks the
449///    batch durable, wakes all followers. Otherwise it parks on
450///    the condvar until a leader covers its seq.
451///
452/// Durability contract is unchanged from v7.19: `execute()`
453/// does not return Ok until the record that describes its
454/// mutation is fsynced. The only change is N callers sharing
455/// one fsync instead of paying one each.
456///
457/// Lock order (deadlock-free): `state` then `file`; never the
458/// reverse. The leader holds `file` WITHOUT `state` during IO so
459/// enqueues continue while fsync runs.
460#[derive(Debug)]
461struct WalGroup {
462    state: Mutex<WalGroupState>,
463    cond: std::sync::Condvar,
464    /// Active chunk file handle. Separate lock from `state` so
465    /// the leader's write+fsync doesn't block concurrent
466    /// enqueues. Swapped by `checkpoint()` at rotation.
467    file: Mutex<File>,
468    /// v7.37.13 (A1.4 / A1.5 TDD) — per-instance fsync-failure
469    /// inject for tests. Was process-wide; cross-test contention
470    /// armed it in one test and consumed it in another's worker
471    /// thread on Linux. Per-WalGroup keeps each test isolated.
472    /// Production builds compile this away (the field is gated to
473    /// cfg(test)).
474    #[cfg(test)]
475    fsync_fail_inject: std::sync::atomic::AtomicBool,
476}
477
478#[derive(Debug)]
479struct WalGroupState {
480    /// Encoded records awaiting flush.
481    buf: Vec<u8>,
482    /// Monotonic enqueue counter (1-based).
483    enqueued_seq: u64,
484    /// Highest seq whose record is fsynced.
485    flushed_seq: u64,
486    /// True while some caller is inside the leader IO section.
487    leader_active: bool,
488    /// Sticky fatal error — a failed fsync poisons the WAL
489    /// (loud, never silent). All current + future waiters error.
490    failed: Option<String>,
491    /// Bytes written to the active chunk since rotation —
492    /// drives the auto-checkpoint trigger.
493    written_len: u64,
494}
495
496/// Ticket returned by the buffered write path; `wait()` blocks
497/// until the record it covers is durable (or the WAL is
498/// poisoned). Cheap to move across threads.
499#[derive(Debug)]
500pub struct WalTicket {
501    group: Arc<WalGroup>,
502    seq: u64,
503}
504
505/// v7.34 (crash-recovery P0 #2) — RAII reset for the WalGroup leader
506/// flag. Electing a leader sets `leader_active = true` and releases the
507/// state lock for the sleep+IO window; if a panic unwinds through that
508/// window the flag would stay true and every follower would park forever
509/// on the condvar — no one left to flush or wake them, the same
510/// total-write hang an unclean stop causes, but self-inflicted. This
511/// guard clears the flag and wakes the followers (so one re-elects) on
512/// ANY drop, including a panic unwind; the normal path disarms it after
513/// resetting the flag itself.
514struct LeaderGuard<'a> {
515    group: &'a WalGroup,
516    armed: bool,
517}
518
519impl Drop for LeaderGuard<'_> {
520    fn drop(&mut self) {
521        if self.armed {
522            let mut g = self.group.state.lock().unwrap_or_else(|e| e.into_inner());
523            g.leader_active = false;
524            drop(g);
525            self.group.cond.notify_all();
526        }
527    }
528}
529
530impl WalGroup {
531    fn new(file: File, initial_len: u64) -> Self {
532        Self {
533            state: Mutex::new(WalGroupState {
534                buf: Vec::new(),
535                enqueued_seq: 0,
536                flushed_seq: 0,
537                leader_active: false,
538                failed: None,
539                written_len: initial_len,
540            }),
541            cond: std::sync::Condvar::new(),
542            file: Mutex::new(file),
543            #[cfg(test)]
544            fsync_fail_inject: std::sync::atomic::AtomicBool::new(false),
545        }
546    }
547
548    /// v7.37.13 (A1.4 / A1.5 TDD) — arm the next sync_data on this
549    /// specific WalGroup to return EIO. One-shot: consumed by the
550    /// next sync_data call. Per-instance so parallel tests don't
551    /// stomp each other (the previous process-wide static had
552    /// cross-test contention on Linux).
553    #[cfg(test)]
554    fn arm_fsync_fail(&self) {
555        self.fsync_fail_inject
556            .store(true, std::sync::atomic::Ordering::Release);
557    }
558
559    /// v7.37.13 (A1.4 / A1.5 TDD) — consume the per-instance
560    /// inject flag for the current sync. Returns true once after
561    /// arm_fsync_fail; false thereafter.
562    #[cfg(test)]
563    fn take_fsync_fail_inject(&self) -> bool {
564        self.fsync_fail_inject
565            .swap(false, std::sync::atomic::Ordering::AcqRel)
566    }
567
568    /// Append `record` to the pending batch. Returns the seq the
569    /// caller must wait on. Called under the engine write lock —
570    /// keep it O(memcpy).
571    fn enqueue(&self, record: &[u8]) -> u64 {
572        let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
573        g.buf.extend_from_slice(record);
574        g.enqueued_seq += 1;
575        g.enqueued_seq
576    }
577
578    /// Block until `seq` is durable. Leader-follower: the first
579    /// arriving waiter flushes for everyone.
580    fn wait_flushed(&self, seq: u64) -> Result<(), EngineError> {
581        let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
582        loop {
583            if let Some(e) = &g.failed {
584                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
585                    format!("WAL poisoned by earlier flush failure: {e}"),
586                )));
587            }
588            if g.flushed_seq >= seq {
589                return Ok(());
590            }
591            if !g.leader_active {
592                // Elect self leader.
593                g.leader_active = true;
594                drop(g);
595                // v7.34 — panic-safety: if anything below unwinds before
596                // `leader_active` is reset, this guard releases it +
597                // wakes a follower to re-elect (else all writers park
598                // forever). Disarmed on the normal path after the reset.
599                let mut leader_guard = LeaderGuard {
600                    group: self,
601                    armed: true,
602                };
603                // v7.20 — commit_delay (PG's same-named knob):
604                // before taking the batch, give in-flight
605                // writers a short window to enqueue so the
606                // shared fsync covers more commits. 150 µs costs
607                // ~3.5% on a solo 4.2 ms fsync but multiplies
608                // batch size under load. Tunable via
609                // SPG_COMMIT_DELAY_US (0 disables).
610                let delay = commit_delay_us();
611                if delay > 0 {
612                    std::thread::sleep(std::time::Duration::from_micros(delay));
613                }
614                let (batch, flush_to) = {
615                    let mut g2 = self.state.lock().unwrap_or_else(|e| e.into_inner());
616                    (core::mem::take(&mut g2.buf), g2.enqueued_seq)
617                };
618                let io_result: std::io::Result<()> = (|| {
619                    let mut f = self.file.lock().unwrap_or_else(|e| e.into_inner());
620                    f.write_all(&batch)?;
621                    let r = wal_sync_data(&mut f);
622                    // v7.37.13 — per-instance inject after the
623                    // actual sync (we still WANT the real sync to
624                    // happen first so kernel state is consistent
625                    // before the simulated failure).
626                    #[cfg(test)]
627                    if self.take_fsync_fail_inject() {
628                        return Err(std::io::Error::other("injected fsync fail (per-instance)"));
629                    }
630                    r
631                })();
632                // v7.37.13 (A1.4) — apply the configured fsync-fail
633                // policy. The handler aborts on the default path
634                // (durability invariant) or returns Err only when
635                // SPG_DATA_SYNC_RETRY=on lets the caller see it.
636                let io_result = match io_result {
637                    Ok(()) => Ok(()),
638                    Err(e) => handle_wal_fsync_fail(e),
639                };
640                g = self.state.lock().unwrap_or_else(|e| e.into_inner());
641                g.leader_active = false;
642                leader_guard.armed = false; // normal completion — disarm
643                match io_result {
644                    Ok(()) => {
645                        g.flushed_seq = flush_to;
646                        g.written_len = g.written_len.saturating_add(batch.len() as u64);
647                    }
648                    Err(e) => {
649                        g.failed = Some(e.to_string());
650                    }
651                }
652                self.cond.notify_all();
653                //
654
655                // Loop continues: either our seq is now covered
656                // (leader path normally returns next iteration)
657                // or the error branch surfaces.
658                continue;
659            }
660            g = self.cond.wait(g).unwrap_or_else(|e| e.into_inner());
661        }
662    }
663
664    /// Drain the pending batch + flush synchronously. Caller must
665    /// guarantee no concurrent enqueues (checkpoint holds the
666    /// engine exclusively). Used before rotation so the marker
667    /// lands in the right chunk.
668    fn flush_now(&self) -> Result<(), EngineError> {
669        let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
670        if let Some(e) = &g.failed {
671            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
672                format!("WAL poisoned: {e}"),
673            )));
674        }
675        let batch = core::mem::take(&mut g.buf);
676        let flush_to = g.enqueued_seq;
677        if batch.is_empty() {
678            return Ok(());
679        }
680        drop(g);
681        let io: std::io::Result<()> = (|| {
682            let mut f = self.file.lock().unwrap_or_else(|e| e.into_inner());
683            f.write_all(&batch)?;
684            let r = wal_sync_data(&mut f);
685            #[cfg(test)]
686            if self.take_fsync_fail_inject() {
687                return Err(std::io::Error::other("injected fsync fail (per-instance)"));
688            }
689            r
690        })();
691        // v7.37.13 (A1.4) — same policy gate as the leader path.
692        let io = match io {
693            Ok(()) => Ok(()),
694            Err(e) => handle_wal_fsync_fail(e),
695        };
696        let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
697        match io {
698            Ok(()) => {
699                g.flushed_seq = flush_to;
700                g.written_len = g.written_len.saturating_add(batch.len() as u64);
701                self.cond.notify_all();
702                Ok(())
703            }
704            Err(e) => {
705                g.failed = Some(e.to_string());
706                self.cond.notify_all();
707                Err(io_err(e))
708            }
709        }
710    }
711
712    /// Swap the active chunk handle (rotation). Caller flushes
713    /// first; both locks taken in canonical order.
714    fn rotate_file(&self, new_file: File) {
715        let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
716        let mut f = self.file.lock().unwrap_or_else(|e| e.into_inner());
717        *f = new_file;
718        g.written_len = 0;
719    }
720
721    fn written_len(&self) -> u64 {
722        let g = self.state.lock().unwrap_or_else(|e| e.into_inner());
723        g.written_len + g.buf.len() as u64
724    }
725}
726
727// ─────────────────────────────────────────────────────────────────────────────
728// CoW-2 (v7.34) — background-checkpoint worker.
729//
730// Splits checkpoint into two halves so the front-end pays only the cheap one:
731//   • Capture (`Database::snapshot_checkpoint_job`) — under &mut self,
732//     Arc-bump the catalog + cheap trailer/cold-segment clones + atomic
733//     commit_lsn load. Front returns to caller in microseconds.
734//   • Execute (`execute_checkpoint_job`, on the worker thread) — serialize
735//     the snapshot, tmp+rename the db / manifest files (each fsynced via
736//     the rename + dir-fsync), enqueue the v4 marker through the WalGroup
737//     (which is already thread-safe so live commits interleave fine),
738//     then rotate the chunk file.
739//
740// Replay floor is the marker LSN captured at front-end time. A crash any
741// time during the worker's sequence is safe: nothing past the previous
742// checkpoint's marker can have been forgotten until the new marker hits
743// the WAL, and live writes between the two go into the same chunk under
744// the old marker — replay re-applies them after restoring the (older)
745// snapshot. snapshot+manifest atomicity (D10) is unchanged from the sync
746// path — CoW-4 tightens it later.
747//
748// Single-instance: a state machine of {pending, inflight} so a new
749// trigger fires only when the worker is fully idle. Any sticky error
750// surfaces on the next `wait()`.
751
752#[derive(Debug)]
753struct CheckpointJob {
754    snapshot: spg_engine::EngineSnapshot,
755    marker_lsn: u64,
756    db_path: PathBuf,
757    wal_dir: PathBuf,
758    wal: Arc<WalGroup>,
759    /// Snapshot-time view of the cold-tier segment set. Carried into the
760    /// worker so any concurrent `freeze_oldest_to_cold` after the trigger
761    /// rides the *next* checkpoint's manifest — same staleness window
762    /// the sync path already had.
763    cold_segments: Vec<(u32, PathBuf)>,
764    /// Shared with `PersistenceCtx` so the worker's chunk rotation is
765    /// visible to subsequent diag / Drop introspection.
766    current_chunk_path: Arc<Mutex<PathBuf>>,
767    /// v7.37.13 (A1.9) — shared stats sink. Worker updates after
768    /// each successful checkpoint so Database::checkpoint_stats()
769    /// + future spg_stat_checkpoint observability see fresh timing.
770    stats: Arc<Mutex<CheckpointStats>>,
771}
772
773/// v7.37.13 (A1.9) — per-checkpoint instrumentation. PG-equivalent
774/// of `LogCheckpointEnd`, with [PG+] percentile buckets over a
775/// rolling window of recent checkpoints (PG just logs the latest).
776///
777/// All durations are microseconds. Fields below "last_*" describe
778/// the most recent checkpoint; "total_count" counts every
779/// successful checkpoint over the process lifetime. The
780/// `recent_total_us` deque holds up to [`CHECKPOINT_STATS_WINDOW`]
781/// total-duration samples for p50/p95/p99 computation.
782#[derive(Debug, Clone, Default)]
783pub struct CheckpointStats {
784    /// Process-lifetime count of successful checkpoints. Failed
785    /// (poisoned, IO error) checkpoints do NOT advance this.
786    pub total_count: u64,
787    /// Snapshot serialize + tmp+rename duration (µs).
788    pub last_write_us: u64,
789    /// WAL flush_now + chunk rotation duration (µs).
790    pub last_sync_us: u64,
791    /// End-to-end checkpoint duration (µs) = write + manifest + sync + rotate.
792    pub last_total_us: u64,
793    /// Bytes of WAL that fell behind the marker (= WAL written
794    /// during this checkpoint, roughly).
795    pub last_wal_bytes: u64,
796    /// Snapshot bytes written to disk.
797    pub last_snapshot_bytes: u64,
798    /// Files synced as part of the checkpoint (snapshot + manifest
799    /// + WAL marker + dir fsyncs). Approximate — counts the major
800    /// disk touches, not every internal sync_data() call.
801    pub last_files_synced: u32,
802    /// [PG+] Rolling window of `last_total_us` for percentile
803    /// computation. Oldest at front, newest at back; bounded at
804    /// [`CHECKPOINT_STATS_WINDOW`].
805    pub recent_total_us: std::collections::VecDeque<u64>,
806}
807
808/// v7.37.13 (A1.9) — how many recent checkpoints we retain for
809/// percentile computation. PG logs only the latest; we keep enough
810/// to surface p99 over a window that's meaningful for short-term
811/// monitoring (~last hour at a typical 60 s checkpoint cadence).
812pub const CHECKPOINT_STATS_WINDOW: usize = 64;
813
814impl CheckpointStats {
815    /// [PG+] p50 / p95 / p99 of `recent_total_us`. Returns
816    /// `(p50, p95, p99)` in microseconds. If the window has fewer
817    /// than 3 samples each value is the last observed sample.
818    #[must_use]
819    pub fn percentiles(&self) -> (u64, u64, u64) {
820        if self.recent_total_us.is_empty() {
821            return (0, 0, 0);
822        }
823        let mut sorted: Vec<u64> = self.recent_total_us.iter().copied().collect();
824        sorted.sort_unstable();
825        let pick = |p: f64| -> u64 {
826            let idx = ((sorted.len() as f64 - 1.0) * p).round() as usize;
827            sorted[idx.min(sorted.len() - 1)]
828        };
829        (pick(0.50), pick(0.95), pick(0.99))
830    }
831}
832
833#[derive(Debug, Default)]
834struct CheckpointState {
835    /// Set by the front when it has a job ready; cleared when the worker
836    /// picks it up.
837    pending: Option<CheckpointJob>,
838    /// True while the worker is mid-execute. `pending.is_some() || inflight`
839    /// defines "busy" for the trigger / wait predicate.
840    inflight: bool,
841    /// Sticky error from the worker's last failure. Cleared when surfaced
842    /// to a `wait()` caller.
843    last_error: Option<EngineError>,
844    /// Drop signal — worker exits after the current job (or immediately if
845    /// idle and no pending).
846    shutdown: bool,
847}
848
849#[derive(Debug)]
850struct CheckpointWorker {
851    state: Arc<(Mutex<CheckpointState>, Condvar)>,
852    handle: Option<JoinHandle<()>>,
853}
854
855impl CheckpointWorker {
856    fn spawn() -> Self {
857        let state: Arc<(Mutex<CheckpointState>, Condvar)> =
858            Arc::new((Mutex::new(CheckpointState::default()), Condvar::new()));
859        let state_for_thread = Arc::clone(&state);
860        let handle = thread::Builder::new()
861            .name("spg-checkpoint".into())
862            .spawn(move || checkpoint_worker_loop(&state_for_thread))
863            .expect("spawn checkpoint worker");
864        Self {
865            state,
866            handle: Some(handle),
867        }
868    }
869
870    /// Try to enqueue a job. Returns `Ok(true)` if the worker accepted it,
871    /// `Ok(false)` if a job was already pending or in flight (skip — the
872    /// next trigger will pick up newer state). Surfaces any sticky error
873    /// from a previous run before considering the new job, so async paths
874    /// can't lose a failure indefinitely.
875    fn try_enqueue(&self, job: CheckpointJob) -> Result<bool, EngineError> {
876        let (lock, cond) = &*self.state;
877        let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
878        if let Some(e) = g.last_error.take() {
879            return Err(e);
880        }
881        if g.pending.is_some() || g.inflight {
882            return Ok(false);
883        }
884        g.pending = Some(job);
885        cond.notify_one();
886        Ok(true)
887    }
888
889    /// Block until the worker is idle (no pending, not in flight). Returns
890    /// any sticky error from the last run; clears it on the way out.
891    fn wait(&self) -> Result<(), EngineError> {
892        let (lock, cond) = &*self.state;
893        let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
894        while g.pending.is_some() || g.inflight {
895            g = cond.wait(g).unwrap_or_else(|e| e.into_inner());
896        }
897        match g.last_error.take() {
898            Some(e) => Err(e),
899            None => Ok(()),
900        }
901    }
902}
903
904impl Drop for CheckpointWorker {
905    fn drop(&mut self) {
906        {
907            let (lock, cond) = &*self.state;
908            let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
909            g.shutdown = true;
910            cond.notify_one();
911        }
912        if let Some(h) = self.handle.take() {
913            let _ = h.join();
914        }
915    }
916}
917
918fn checkpoint_worker_loop(state: &Arc<(Mutex<CheckpointState>, Condvar)>) {
919    let (lock, cond) = &**state;
920    loop {
921        let job = {
922            let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
923            while g.pending.is_none() && !g.shutdown {
924                g = cond.wait(g).unwrap_or_else(|e| e.into_inner());
925            }
926            if g.pending.is_none() {
927                // shutdown with no pending → exit cleanly.
928                return;
929            }
930            // Even on shutdown, drain the pending job first so the Drop-time
931            // final checkpoint is durable before exit.
932            let job = g.pending.take().expect("loop invariant");
933            g.inflight = true;
934            job
935        };
936        let result = execute_checkpoint_job(job);
937        {
938            let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
939            g.inflight = false;
940            if let Err(e) = result {
941                g.last_error = Some(e);
942            }
943            cond.notify_all();
944        }
945    }
946}
947
948fn execute_checkpoint_job(job: CheckpointJob) -> Result<(), EngineError> {
949    // v7.37.13 (A1.9) — measure each phase so checkpoint_stats() can
950    // report write_us / sync_us / total_us + the file-sync count.
951    let job_start = std::time::Instant::now();
952    let write_start = std::time::Instant::now();
953    let mut files_synced: u32 = 0;
954    let snapshot_bytes_baseline = job.wal.written_len();
955    // 1. Serialize the captured snapshot. Heavy; this is the whole point
956    //    of CoW — it runs off the engine borrow.
957    let snapshot = job.snapshot.serialize();
958    let snapshot_bytes = snapshot.len() as u64;
959    // 2. Snapshot tmp+rename. Atomic on POSIX; rename implicitly fsyncs
960    //    the data the next directory walk sees.
961    let tmp = {
962        let mut t = job.db_path.clone();
963        let mut name = t
964            .file_name()
965            .map(std::ffi::OsStr::to_os_string)
966            .unwrap_or_default();
967        name.push(".tmp");
968        t.set_file_name(name);
969        t
970    };
971    // v7.38 (read01 P5.07) — the snapshot data must be durable BEFORE the
972    // WAL checkpoint marker is fsynced (step 4). Otherwise a crash could
973    // leave the marker (recovery believes the checkpoint completed) pointing
974    // at a snapshot the OS never flushed. fsync the tmp file, then fsync the
975    // parent directory after the rename so the rename itself survives a
976    // crash.
977    {
978        use std::io::Write;
979        let mut f = std::fs::File::create(&tmp).map_err(io_err)?;
980        f.write_all(&snapshot).map_err(io_err)?;
981        f.sync_all().map_err(io_err)?;
982    }
983    files_synced += 1; // snapshot file
984    // v7.38 P0 元机制 A — checkpoint CoW swap boundary. Pre fires after
985    // the tmp file is written and fsynced but BEFORE the rename. Tests
986    // use this to race a concurrent read against an in-flight swap.
987    spg_engine::injection_point!("checkpoint_cow_swap_pre", &tmp);
988    std::fs::rename(&tmp, &job.db_path).map_err(io_err)?;
989    if let Some(parent) = job.db_path.parent() {
990        fsync_dir(parent);
991    }
992    let write_us = write_start.elapsed().as_micros() as u64;
993    let sync_start = std::time::Instant::now();
994    // v7.38 P0 元机制 A — post-rename: the new snapshot is the
995    // authoritative on-disk image. Tests use this to inject a delay
996    // before the manifest update (or simulate a crash here to verify
997    // open_path's snapshot+manifest divergence recovery path).
998    spg_engine::injection_point!("checkpoint_cow_swap_post", &job.db_path);
999    // 3. Manifest tmp+rename (cold tier present).
1000    if !job.cold_segments.is_empty() {
1001        let snap_crc = spg_crypto::crc32::crc32(&snapshot);
1002        let entries: Vec<ColdSegmentEntry> = job
1003            .cold_segments
1004            .iter()
1005            .filter_map(|(segment_id, path)| {
1006                let bytes = std::fs::read(path).ok()?;
1007                Some(ColdSegmentEntry {
1008                    segment_id: *segment_id,
1009                    path: path.clone(),
1010                    crc32: spg_crypto::crc32::crc32(&bytes),
1011                })
1012            })
1013            .collect();
1014        let manifest = CatalogManifest {
1015            catalog_crc32: snap_crc,
1016            cold_segments: entries,
1017            wal_baseline_offset: 0,
1018        };
1019        let m_bytes = manifest.serialize();
1020        let m_path = spg_manifest_path(&job.db_path);
1021        if let Some(dir) = m_path.parent() {
1022            std::fs::create_dir_all(dir).map_err(io_err)?;
1023        }
1024        let m_tmp = {
1025            let mut t = m_path.clone();
1026            let mut name = t
1027                .file_name()
1028                .map(std::ffi::OsStr::to_os_string)
1029                .unwrap_or_default();
1030            name.push(".tmp");
1031            t.set_file_name(name);
1032            t
1033        };
1034        // v7.38 (read01 P5.07) — durable manifest before the marker too.
1035        {
1036            use std::io::Write;
1037            let mut f = std::fs::File::create(&m_tmp).map_err(io_err)?;
1038            f.write_all(&m_bytes).map_err(io_err)?;
1039            f.sync_all().map_err(io_err)?;
1040        }
1041        std::fs::rename(&m_tmp, &m_path).map_err(io_err)?;
1042        if let Some(parent) = m_path.parent() {
1043            fsync_dir(parent);
1044        }
1045        files_synced += 1; // manifest file
1046    }
1047    // 4. Enqueue the v4 checkpoint marker carrying the captured LSN. The
1048    //    WalGroup is thread-safe so a live commit can interleave — the
1049    //    marker's LSN, not its position in the chunk, anchors replay.
1050    let marker_ts = wall_clock_micros();
1051    // v7.37.13 (A1.2 + A1.3) — v6 encoder uses CRC32C; the prev_lsn
1052    // for a checkpoint marker is the prior committed LSN (the
1053    // marker itself does not advance commit_lsn — the snapshot was
1054    // captured at marker_lsn).
1055    let prev_lsn = job.marker_lsn.saturating_sub(1);
1056    let marker = encode_v6_checkpoint_marker(prev_lsn, job.marker_lsn, marker_ts, &job.db_path);
1057    job.wal.enqueue(&marker);
1058    job.wal.flush_now()?;
1059    files_synced += 1; // WAL marker fsync via flush_now
1060    // 5. Rotate the active chunk. New commits land in the fresh chunk;
1061    //    pre-marker history stays addressable in the old chunk for PITR /
1062    //    retention. The shared `current_chunk_path` is updated under its
1063    //    own lock before the WalGroup swap so diag readers never see a
1064    //    handle that no longer matches the recorded path.
1065    let new_chunk_path = job
1066        .wal_dir
1067        .join(chunk_filename(marker_ts, job.marker_lsn + 1));
1068    let new_handle = OpenOptions::new()
1069        .create(true)
1070        .append(true)
1071        .read(true)
1072        .open(&new_chunk_path)
1073        .map_err(io_err)?;
1074    fsync_dir(&job.wal_dir);
1075    {
1076        let mut p = job
1077            .current_chunk_path
1078            .lock()
1079            .unwrap_or_else(|e| e.into_inner());
1080        *p = new_chunk_path;
1081    }
1082    job.wal.rotate_file(new_handle);
1083    files_synced += 1; // WAL dir fsync (chunk rotation)
1084
1085    // v7.37.13 (A1.9) — publish per-checkpoint stats to the shared
1086    // sink so Database::checkpoint_stats() + future
1087    // spg_stat_checkpoint surface fresh timing immediately after
1088    // this job returns.
1089    let total_us = job_start.elapsed().as_micros() as u64;
1090    let sync_us = sync_start.elapsed().as_micros() as u64;
1091    let wal_bytes = job
1092        .wal
1093        .written_len()
1094        .saturating_sub(snapshot_bytes_baseline);
1095    {
1096        let mut s = job.stats.lock().unwrap_or_else(|e| e.into_inner());
1097        s.total_count = s.total_count.saturating_add(1);
1098        s.last_write_us = write_us;
1099        s.last_sync_us = sync_us;
1100        s.last_total_us = total_us;
1101        s.last_wal_bytes = wal_bytes;
1102        s.last_snapshot_bytes = snapshot_bytes;
1103        s.last_files_synced = files_synced;
1104        if s.recent_total_us.len() >= CHECKPOINT_STATS_WINDOW {
1105            s.recent_total_us.pop_front();
1106        }
1107        s.recent_total_us.push_back(total_us);
1108    }
1109
1110    Ok(())
1111}
1112
1113impl WalTicket {
1114    /// Block until the record this ticket covers is durable.
1115    ///
1116    /// Under `SPG_SYNCHRONOUS_COMMIT=off` this returns
1117    /// immediately — the background flusher (or the next
1118    /// checkpoint / clean shutdown) makes the record durable
1119    /// within `SPG_WAL_WRITER_DELAY_MS`. Same contract as PG's
1120    /// `synchronous_commit = off`.
1121    ///
1122    /// # Errors
1123    /// Surfaces the leader's IO error if the batch flush failed
1124    /// (the WAL is then poisoned for all subsequent writes).
1125    pub fn wait(&self) -> Result<(), EngineError> {
1126        if !synchronous_commit_on() {
1127            return Ok(());
1128        }
1129        self.group.wait_flushed(self.seq)
1130    }
1131}
1132
1133/// v7.19 P3 — retention sweep loop. Runs in a dedicated thread
1134/// spawned by `Database::open_path` when `SPG_PITR_RETENTION_HOURS`
1135/// is set to a non-zero value. Wakes every
1136/// `SPG_PITR_RETENTION_CHECK_SEC` (default 60 s), enumerates chunks
1137/// under `wal_dir`, archives via `SPG_PITR_ARCHIVE_CMD` if set, and
1138/// deletes anything older than `retention_hours`.
1139///
1140/// Loud-failure posture matches PG's `archive_command`: if the
1141/// archive command returns non-zero, the chunk stays on disk and
1142/// a warning prints to stderr. The retention sweep doesn't delete
1143/// a chunk it failed to archive.
1144fn retention_sweep_loop(
1145    wal_dir: PathBuf,
1146    retention_hours: u64,
1147    check_interval: std::time::Duration,
1148    archive_cmd: Option<String>,
1149    shutdown: Arc<AtomicBool>,
1150) {
1151    while !shutdown.load(Ordering::SeqCst) {
1152        if let Err(e) = retention_sweep_once(&wal_dir, retention_hours, archive_cmd.as_deref()) {
1153            eprintln!("spg-embedded: retention sweep error: {e}");
1154        }
1155        // Sleep in short ticks so shutdown isn't blocked on a
1156        // 60 s naptime when Drop signals.
1157        let mut elapsed = std::time::Duration::ZERO;
1158        let tick = std::time::Duration::from_millis(250);
1159        while elapsed < check_interval {
1160            if shutdown.load(Ordering::SeqCst) {
1161                return;
1162            }
1163            std::thread::sleep(tick);
1164            elapsed += tick;
1165        }
1166    }
1167}
1168
1169/// v7.19 P3 — one retention sweep pass over `wal_dir`. Extracted
1170/// from the loop so tests can drive it directly. Public so the
1171/// e2e_pitr_retention integration test (and any future operator
1172/// tooling that wants synchronous retention) can call it.
1173pub fn retention_sweep_once(
1174    wal_dir: &Path,
1175    retention_hours: u64,
1176    archive_cmd: Option<&str>,
1177) -> std::io::Result<()> {
1178    if !wal_dir.exists() {
1179        return Ok(());
1180    }
1181    let now_us = wall_clock_micros();
1182    let cutoff_us = (now_us as i128 - (retention_hours as i128 * 3_600 * 1_000_000)) as i64;
1183    let chunks = sorted_wal_chunks(wal_dir)?;
1184    for chunk in chunks {
1185        // Don't sweep the most-recent chunk; it's the live one
1186        // execute() is appending to. Compare against the largest
1187        // filename-prefix unix_us.
1188        let stem = match chunk.file_stem().and_then(|s| s.to_str()) {
1189            Some(s) => s,
1190            None => continue,
1191        };
1192        let chunk_us: i64 = stem
1193            .split_once('_')
1194            .and_then(|(prefix, _)| i64::from_str_radix(prefix, 16).ok())
1195            .unwrap_or(0);
1196        if chunk_us >= cutoff_us {
1197            continue;
1198        }
1199        // Archive first if requested.
1200        if let Some(cmd) = archive_cmd {
1201            if !cmd.is_empty() {
1202                let output = std::process::Command::new("sh")
1203                    .arg("-c")
1204                    .arg(cmd)
1205                    .arg("--")
1206                    .arg(&chunk)
1207                    .output()?;
1208                if !output.status.success() {
1209                    eprintln!(
1210                        "spg-embedded: SPG_PITR_ARCHIVE_CMD failed for {} (exit {}); chunk stays on disk",
1211                        chunk.display(),
1212                        output.status.code().unwrap_or(-1)
1213                    );
1214                    continue;
1215                }
1216            }
1217        }
1218        // Delete the chunk + its sibling .checksum if present.
1219        if let Err(e) = std::fs::remove_file(&chunk) {
1220            eprintln!(
1221                "spg-embedded: retention remove {} failed: {e}",
1222                chunk.display()
1223            );
1224            continue;
1225        }
1226        let mut cs = chunk.clone();
1227        let mut name = cs.file_name().map(|n| n.to_os_string()).unwrap_or_default();
1228        name.push(".checksum");
1229        cs.set_file_name(name);
1230        let _ = std::fs::remove_file(&cs);
1231    }
1232    Ok(())
1233}
1234
1235/// v7.20 — group-commit delay window in µs (PG `commit_delay`
1236/// analogue). The flush leader sleeps this long before taking
1237/// the batch so concurrent writers pile in. Default 150 µs;
1238/// `SPG_COMMIT_DELAY_US=0` disables.
1239fn commit_delay_us() -> u64 {
1240    static CACHED: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
1241    *CACHED.get_or_init(|| {
1242        std::env::var("SPG_COMMIT_DELAY_US")
1243            .ok()
1244            .and_then(|s| s.parse::<u64>().ok())
1245            .unwrap_or(150)
1246    })
1247}
1248
1249/// v7.20 — PG `synchronous_commit` analogue. `on` (default):
1250/// `execute()` blocks until its WAL record is fsynced —
1251/// zero-loss durability. `off`: `execute()` returns after the
1252/// in-memory mutation + WAL enqueue; a background flusher
1253/// thread writes + fsyncs every `SPG_WAL_WRITER_DELAY_MS`
1254/// (default 200 ms — PG's `wal_writer_delay` default). Crash
1255/// window = up to one flush interval of confirmed-but-unsynced
1256/// commits — exactly the trade PG documents for the same
1257/// setting. Clean shutdown (Drop / checkpoint) always flushes.
1258fn synchronous_commit_on() -> bool {
1259    static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1260    *CACHED.get_or_init(|| {
1261        !std::env::var("SPG_SYNCHRONOUS_COMMIT")
1262            .map(|v| v.eq_ignore_ascii_case("off") || v == "0" || v.eq_ignore_ascii_case("false"))
1263            .unwrap_or(false)
1264    })
1265}
1266
1267/// v7.20 — background WAL flusher cadence for
1268/// `SPG_SYNCHRONOUS_COMMIT=off` (PG `wal_writer_delay`).
1269fn wal_writer_delay_ms() -> u64 {
1270    static CACHED: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
1271    *CACHED.get_or_init(|| {
1272        std::env::var("SPG_WAL_WRITER_DELAY_MS")
1273            .ok()
1274            .and_then(|s| s.parse::<u64>().ok())
1275            .filter(|&n| n > 0)
1276            .unwrap_or(200)
1277    })
1278}
1279
1280fn pitr_retention_hours() -> u64 {
1281    std::env::var("SPG_PITR_RETENTION_HOURS")
1282        .ok()
1283        .and_then(|s| s.parse::<u64>().ok())
1284        .unwrap_or(0)
1285}
1286
1287fn pitr_retention_check_sec() -> u64 {
1288    std::env::var("SPG_PITR_RETENTION_CHECK_SEC")
1289        .ok()
1290        .and_then(|s| s.parse::<u64>().ok())
1291        .filter(|&n| n > 0)
1292        .unwrap_or(60)
1293}
1294
1295fn pitr_archive_cmd() -> Option<String> {
1296    std::env::var("SPG_PITR_ARCHIVE_CMD")
1297        .ok()
1298        .filter(|s| !s.is_empty())
1299}
1300
1301/// v7.19 — replay every record from `wal_bytes` whose
1302/// `commit_lsn` is strictly greater than `floor_lsn`. v3 records
1303/// (no LSN) and v4 records with `commit_lsn <= floor_lsn` are
1304/// skipped — the snapshot loaded ahead of this call already
1305/// reflects them, and re-applying would DuplicateTable /
1306/// double-insert. v3 records inside the legacy migration chunk
1307/// always apply because the migration sets `floor_lsn = 0` and
1308/// v3 records carry no LSN to compare; the pre-migration
1309/// behaviour (every record replays) is what the migration
1310/// preserves.
1311///
1312/// Returns the count of records successfully applied. Same
1313/// torn-tail semantics as `replay_wal_into_engine`.
1314fn replay_wal_filtered(
1315    wal_bytes: &[u8],
1316    engine: &mut Engine,
1317    floor_lsn: u64,
1318    quarantine: &mut Vec<QuarantinedStmt>,
1319) -> Result<usize, String> {
1320    let records = parse_wal_records(wal_bytes)?;
1321    let total_records = records.len();
1322    let mut applied = 0usize;
1323    // v7.37.8 — periodic heartbeat. Operators / mailrs see in the
1324    // container log that replay is making progress (or, if no line
1325    // appears for 30+ s, that it isn't). The `SPG_REPLAY_HEARTBEAT_MS`
1326    // env var tunes the cadence; 0 disables. Default 5 s — frequent
1327    // enough that mailrs's 15 s pool retries see at least one beat,
1328    // sparse enough not to flood normal startup logs.
1329    let heartbeat_ms = std::env::var("SPG_REPLAY_HEARTBEAT_MS")
1330        .ok()
1331        .and_then(|s| s.parse::<u64>().ok())
1332        .unwrap_or(5_000);
1333    let mut last_beat = std::time::Instant::now();
1334    let replay_started = last_beat;
1335    // v7.37.7 A.1 — per-record-type timing histogram gated on env var.
1336    // Records mailrs prod snapshot's WAL has ~thousands of WAL_V5_ROW_REDO
1337    // entries; v7.37.5 ack claimed batched apply_redo brought replay to
1338    // ~500ms but fresh-extract measurement shows ~250s. This histogram
1339    // splits ROW_REDO vs SQL re-execute time so the fix target is concrete.
1340    let timing = std::env::var_os("SPG_OPEN_PATH_TIMING").is_some();
1341    let mut redo_count = 0u64;
1342    let mut redo_us = 0u128;
1343    let mut sql_count = 0u64;
1344    let mut sql_us = 0u128;
1345    let mut marker_count = 0u64;
1346    let mut skip_count = 0u64;
1347    for r in &records {
1348        // Skip markers + non-SQL records.
1349        if r.type_byte == WAL_V3_TYPE_DURABILITY_CHECKPOINT
1350            || r.type_byte == WAL_V4_TYPE_CHECKPOINT_MARKER
1351        {
1352            marker_count += 1;
1353            continue;
1354        }
1355        // v4 SQL records carry an LSN. Apply iff strictly above
1356        // the snapshot floor.
1357        if r.type_byte == WAL_V4_TYPE_AUTO_COMMIT_SQL
1358            || r.type_byte == WAL_V4_TYPE_TX_COMMIT_SQL
1359            || r.type_byte == WAL_V5_TYPE_ROW_REDO
1360        {
1361            if let Some(lsn) = r.commit_lsn {
1362                if lsn <= floor_lsn {
1363                    skip_count += 1;
1364                    continue;
1365                }
1366            }
1367        }
1368        // v7.34 (crash-recovery P0 #2) — row-level redo record: apply the
1369        // physical changes directly (O(changed rows)) instead of
1370        // re-executing SQL (the O(records × rows) statement-replay that
1371        // hung the mailrs P0). The payload is `encode_redo_log` bytes, not
1372        // SQL, so it never enters the from_utf8 / split_statements path.
1373        if r.type_byte == WAL_V5_TYPE_ROW_REDO {
1374            let t = std::time::Instant::now();
1375            let changes = spg_storage::decode_redo_log(r.sql)
1376                .map_err(|e| format!("redo decode at offset {}: {e:?}", r.offset))?;
1377            engine
1378                .apply_redo(&changes)
1379                .map_err(|e| format!("redo apply at offset {}: {e:?}", r.offset))?;
1380            redo_us += t.elapsed().as_micros();
1381            redo_count += 1;
1382            applied += 1;
1383            // v7.37.8 — emit heartbeat (operator visibility — see
1384            // CHANGELOG v7.37.8).
1385            if heartbeat_ms > 0 && last_beat.elapsed().as_millis() as u64 >= heartbeat_ms {
1386                eprintln!(
1387                    "[spg replay heartbeat] applied={applied}/{total_records} \
1388                     ({:.1}%, elapsed {:.1}s)",
1389                    100.0 * applied as f64 / total_records.max(1) as f64,
1390                    replay_started.elapsed().as_secs_f64()
1391                );
1392                last_beat = std::time::Instant::now();
1393            }
1394            continue;
1395        }
1396        // v3 records (type 0x01, no LSN) always apply — the
1397        // legacy migration path is the only place they appear,
1398        // and floor_lsn=0 there.
1399        let sql = match std::str::from_utf8(r.sql) {
1400            Ok(s) => s,
1401            Err(e) => return Err(format!("non-UTF-8 SQL at offset {}: {e}", r.offset)),
1402        };
1403        // v7.21 — a tx-commit record carries the whole transaction
1404        // as a `";\n"`-joined script; auto-commit records are a
1405        // single statement, for which split_statements is a no-op.
1406        //
1407        // v7.30.1 (mailrs round-24 ask 2) — a statement the engine
1408        // REJECTS is quarantined, not fatal: "one statement failed
1409        // to replay" ≠ "the catalog is corrupt". Framing damage
1410        // (parse_wal_records / non-UTF-8 above) still errors — that
1411        // IS corruption. Subsequent statements of a tx script keep
1412        // applying: the bricking class is a no-op-at-runtime
1413        // statement that re-applies non-idempotently, and skipping
1414        // just it reconstructs the runtime state.
1415        let t = std::time::Instant::now();
1416        for stmt in split_statements(sql) {
1417            if let Err(e) = engine.execute(stmt) {
1418                quarantine.push(QuarantinedStmt {
1419                    offset: r.offset,
1420                    sql: stmt.to_string(),
1421                    error: format!("{e:?}"),
1422                });
1423            }
1424        }
1425        sql_us += t.elapsed().as_micros();
1426        sql_count += 1;
1427        applied += 1;
1428        // v7.37.8 — emit heartbeat. Duplicated against the V5
1429        // ROW_REDO branch above; kept duplicated rather than
1430        // factored so the hot loop stays readable.
1431        if heartbeat_ms > 0 && last_beat.elapsed().as_millis() as u64 >= heartbeat_ms {
1432            eprintln!(
1433                "[spg replay heartbeat] applied={applied}/{total_records} \
1434                 ({:.1}%, elapsed {:.1}s)",
1435                100.0 * applied as f64 / total_records.max(1) as f64,
1436                replay_started.elapsed().as_secs_f64()
1437            );
1438            last_beat = std::time::Instant::now();
1439        }
1440    }
1441    if timing {
1442        eprintln!(
1443            "[replay_wal_filtered] total_records={} applied={} redo={} ({:.3}s) sql={} ({:.3}s) marker={} skip_lsn={}",
1444            records.len(),
1445            applied,
1446            redo_count,
1447            redo_us as f64 / 1_000_000.0,
1448            sql_count,
1449            sql_us as f64 / 1_000_000.0,
1450            marker_count,
1451            skip_count,
1452        );
1453    }
1454    Ok(applied)
1455}
1456
1457/// v7.30.1 (mailrs round-24 ask 2) — one statement that failed to
1458/// re-apply during boot replay. Kept for forensics in a
1459/// `quarantine-*.log` beside the WAL chunks; the boot continues.
1460struct QuarantinedStmt {
1461    offset: usize,
1462    sql: String,
1463    error: String,
1464}
1465
1466fn format_quarantine_line(q: &QuarantinedStmt) -> String {
1467    format!("offset {}: {}\n  rejected: {}\n", q.offset, q.sql, q.error)
1468}
1469
1470/// v7.19 — WAL chunk filename format. Zero-padded 16-digit
1471/// hex on both parts so default lexicographic sort matches
1472/// numeric order, with the unix_us prefix coming first so
1473/// the on-disk listing is chronological too.
1474/// v7.34 (crash-recovery P0 #2) — fsync a directory so a newly created
1475/// file's entry is durable. `sync_data` on a chunk file persists its
1476/// bytes but NOT the parent directory entry that names it; a power loss
1477/// after creating a fresh WAL chunk could lose that entry and make the
1478/// chunk (and the committed records in it) unreachable on restart.
1479/// Best-effort — a platform that rejects directory fsync is no worse off.
1480fn fsync_dir(dir: &Path) {
1481    if let Ok(f) = File::open(dir) {
1482        let _ = f.sync_all();
1483    }
1484    #[cfg(test)]
1485    {
1486        // v7.37.13 (A1.6 TDD) — count call sites so tests can verify
1487        // that durability-critical paths (segment rename, WAL chunk
1488        // rotation, ...) actually reach this helper. The counter is
1489        // gated to `cfg(test)` so release builds carry zero overhead.
1490        FSYNC_DIR_CALL_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1491    }
1492}
1493
1494/// v7.37.13 (A1.6 TDD) — test-only call counter for [`fsync_dir`].
1495/// Tests that need to assert "this path durably fsynced its parent
1496/// directory" read this before and after the exercise and check the
1497/// delta. Not exported outside the test cfg; release builds don't
1498/// even allocate the static.
1499#[cfg(test)]
1500pub(crate) static FSYNC_DIR_CALL_COUNT: std::sync::atomic::AtomicU64 =
1501    std::sync::atomic::AtomicU64::new(0);
1502
1503// v7.37.13 (A1.7) — POSIX_FADV_DONTNEED on segment close.
1504//
1505// After a cold segment is written + renamed into the segments dir,
1506// give the kernel a hint that the data won't be needed again soon
1507// so it can evict the bytes from the page cache. Without this, a
1508// long-running embedded process that freezes a steady trickle of
1509// segments accumulates a stale page-cache footprint proportional to
1510// the cold tier — which then competes with hot-tier reads for
1511// memory and crowds out actually-useful pages.
1512//
1513// PG's equivalent posture: `pg_flush_data` + posix_fadvise on
1514// FlushBuffer / FlushRelationBuffers. Same intent — kernel doesn't
1515// know "this is cold storage", we have to tell it.
1516//
1517// Platform: posix_fadvise is Linux-only in the form we want. macOS
1518// uses `F_RDADVISE` (no DONTNEED), Windows has no equivalent. The
1519// non-Linux stub keeps the call site uniform; the test counter
1520// bumps on both so tests pass on dev macOS while the production
1521// fix actually fires on the Linux servers customers run.
1522
1523/// v7.37.13 (A1.7 TDD) — test-only call counter for [`fadvise_dontneed_file`].
1524#[cfg(test)]
1525pub(crate) static FADVISE_DONTNEED_CALL_COUNT: std::sync::atomic::AtomicU64 =
1526    std::sync::atomic::AtomicU64::new(0);
1527
1528#[cfg(target_os = "linux")]
1529#[allow(unsafe_code)] // extern C posix_fadvise binding; isolated.
1530unsafe extern "C" {
1531    fn posix_fadvise(fd: i32, offset: i64, len: i64, advice: i32) -> i32;
1532}
1533
1534/// v7.37.13 (A1.7) — hint the kernel that the bytes of `path` won't
1535/// be needed again soon (POSIX_FADV_DONTNEED). Best-effort: open
1536/// errors and fadvise errors are both swallowed because the worst
1537/// case is "page cache eviction is slightly delayed", which is
1538/// strictly better than "freeze fails because hinting failed".
1539#[cfg(target_os = "linux")]
1540fn fadvise_dontneed_file(path: &Path) {
1541    use std::os::unix::io::AsRawFd;
1542    const POSIX_FADV_DONTNEED: i32 = 4;
1543    if let Ok(f) = File::open(path) {
1544        #[allow(unsafe_code)]
1545        unsafe {
1546            // Offset 0, length 0 = "the entire file".
1547            posix_fadvise(f.as_raw_fd(), 0, 0, POSIX_FADV_DONTNEED);
1548        }
1549    }
1550    #[cfg(test)]
1551    {
1552        FADVISE_DONTNEED_CALL_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1553    }
1554}
1555
1556/// v7.37.13 (A1.7) — no-op stub on platforms without
1557/// POSIX_FADV_DONTNEED. Call sites stay uniform; the production
1558/// Linux deployment is the one that actually benefits.
1559#[cfg(not(target_os = "linux"))]
1560fn fadvise_dontneed_file(_path: &Path) {
1561    // macOS / Windows / BSD: no portable equivalent of
1562    // POSIX_FADV_DONTNEED. Document and move on — the call site
1563    // is reached the same way as on Linux so tests pass on dev
1564    // workstations, the Linux build is the one that actually hints
1565    // the kernel.
1566    #[cfg(test)]
1567    {
1568        FADVISE_DONTNEED_CALL_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1569    }
1570}
1571
1572// v7.37.13 (A1.4 / A1.5) — fsync-failure policy.
1573//
1574// Default behaviour: a WAL fsync failure is a durability invariant
1575// violation. The honest answer is to stop the process so the
1576// supervisor restarts and replay re-establishes a consistent state
1577// from the last good WAL boundary. Continuing on a poisoned WAL
1578// (the pre-v7.37.13 behaviour) leaves every subsequent commit
1579// claiming durability it does not have.
1580//
1581// Opt-out (A1.5): `SPG_DATA_SYNC_RETRY=on` keeps the old behaviour
1582// (poison the WAL + surface Err on every later call). Operators
1583// who already have a higher-level supervisor or who specifically
1584// want a graceful failure path can flip this.
1585//
1586// Implementation notes:
1587//   * The env var is read once and cached (OnceLock). Re-export of
1588//     the var after process start is intentionally ignored — the
1589//     durability policy is a process-lifetime invariant, not a
1590//     runtime tunable.
1591//   * The `#[cfg(test)] FSYNC_RETRY_OVERRIDE` atomic lets the test
1592//     suite drive the policy without touching the env (which is
1593//     not thread-safe across parallel tests on modern stdlib).
1594//   * `FSYNC_FAIL_INJECT` is a one-shot test-only switch that
1595//     forces the next WAL `sync_data` to return EIO so the retry
1596//     path can be exercised without a real disk failure. Always
1597//     consumed via `swap(false, ...)` so one injection fires once.
1598
1599/// v7.37.13 (A1.4) — true when the operator opted into the
1600/// pre-v7.37.13 poison-and-return-Err behaviour. False (default)
1601/// causes [`handle_wal_fsync_fail`] to `std::process::abort()`
1602/// after logging.
1603fn data_sync_retry_on() -> bool {
1604    #[cfg(test)]
1605    {
1606        let v = FSYNC_RETRY_OVERRIDE.load(std::sync::atomic::Ordering::Acquire);
1607        if v == 1 {
1608            return true;
1609        }
1610        if v == 0 {
1611            return false;
1612        }
1613        // v < 0 → fall through to env-based reading.
1614    }
1615    static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1616    *CACHED.get_or_init(|| std::env::var("SPG_DATA_SYNC_RETRY").as_deref() == Ok("on"))
1617}
1618
1619/// v7.37.13 (A1.4) — central fsync-failure handler. Returns `Err`
1620/// only when the operator opted in to the legacy retry path; the
1621/// default aborts the process so durability invariants stay honest.
1622fn handle_wal_fsync_fail(err: std::io::Error) -> std::io::Result<()> {
1623    if data_sync_retry_on() {
1624        return Err(err);
1625    }
1626    eprintln!(
1627        "[spg] FATAL: WAL fsync failed: {err}. Aborting to honor durability \
1628         invariant. Set SPG_DATA_SYNC_RETRY=on to disable this and continue \
1629         on a poisoned WAL (caller will see Err on every subsequent commit)."
1630    );
1631    #[cfg(test)]
1632    {
1633        // Tests don't want the runner to die. Record the would-be
1634        // abort and panic so catch_unwind / assert_panics can pick
1635        // it up. Production callers never reach this branch (they
1636        // hit the abort below).
1637        FSYNC_PANIC_OBSERVED.store(true, std::sync::atomic::Ordering::Release);
1638        panic!("test-mode wal-fsync abort: {err}");
1639    }
1640    #[cfg(not(test))]
1641    {
1642        std::process::abort();
1643    }
1644}
1645
1646/// v7.37.13 (A1.4) — `#[cfg(test)]` override for [`data_sync_retry_on`].
1647///   -1 = use env (default, production path)
1648///    0 = force OFF (= default policy, PANIC on fsync fail)
1649///    1 = force ON  (= legacy retry path)
1650#[cfg(test)]
1651pub(crate) static FSYNC_RETRY_OVERRIDE: std::sync::atomic::AtomicI8 =
1652    std::sync::atomic::AtomicI8::new(-1);
1653
1654/// v7.37.13 (A1.4) — `#[cfg(test)]` witness: set to `true` when the
1655/// default (PANIC) branch fires inside a test (we panic instead of
1656/// abort under test cfg so the runner survives, then assert this).
1657#[cfg(test)]
1658pub(crate) static FSYNC_PANIC_OBSERVED: std::sync::atomic::AtomicBool =
1659    std::sync::atomic::AtomicBool::new(false);
1660
1661/// v7.37.13 (A1.4) — thin alias for `File::sync_data`. Was a wrapper
1662/// for #[cfg(test)] global inject in earlier drafts; injection is
1663/// now per-WalGroup (see `WalGroup::arm_fsync_fail`) so this helper
1664/// is straight-through on every build. Kept as a named helper so
1665/// future fsync-related policies (e.g. SPG_WAL_BARRIER mode) have
1666/// a single chokepoint to hook.
1667#[inline]
1668fn wal_sync_data(f: &mut File) -> std::io::Result<()> {
1669    WAL_FSYNC_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1670    f.sync_data()
1671}
1672
1673/// How many times the WAL has been fsynced, for tests that need to say
1674/// what group-commit actually promises: N concurrent writes cost far
1675/// fewer than N durable syncs.
1676///
1677/// Round 858 — `group_commit.rs` asserted that instead through the
1678/// clock, "64 inserts in under 128 ms, since serial would be ~256". A
1679/// wall-clock stand-in for batching answers the machine rather than the
1680/// engine: it fails on a busy box that batches perfectly, and passes on
1681/// a fast disk that batches nothing. The count does not move when the
1682/// machine does. Same shape as `MATVIEW_DELTA_APPLIED` next door in
1683/// spg-engine, and the chokepoint above was kept for exactly this.
1684pub static WAL_FSYNC_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1685
1686fn chunk_filename(unix_us: i64, leading_lsn: u64) -> String {
1687    // Negative timestamps shouldn't happen in practice (we sit
1688    // post-1970), but clamp to 0 so the zero-padded
1689    // representation stays sortable.
1690    let us = unix_us.max(0) as u64;
1691    format!("{us:016x}_{leading_lsn:016x}.wal")
1692}
1693
1694/// v7.19 — filename used for the legacy single-file WAL when
1695/// `open_path` migrates a v7.18-layout database into the new
1696/// chunk directory. Lexicographically smallest possible value
1697/// so subsequent chunks sort after it.
1698fn legacy_chunk_filename() -> String {
1699    chunk_filename(0, 0)
1700}
1701
1702/// CoW-4 (v7.34) — D10 fallback: read one cold-segment file and
1703/// hand its bytes to the catalog. The segment binary is self-validating
1704/// (magic + internal CRC32 via `OwnedSegment::from_bytes`), so we don't
1705/// need the manifest's `segment_crc32` to trust it. Returns `true` on a
1706/// successful attach (caller bumps `cold_segment_paths`), `false` on a
1707/// per-segment failure that is logged but doesn't abort boot.
1708fn attach_segment_from_disk(engine: &mut Engine, segment_id: u32, path: &Path) -> bool {
1709    if engine.catalog().cold_segment(segment_id).is_some() {
1710        return true;
1711    }
1712    let bytes = match std::fs::read(path) {
1713        Ok(b) => b,
1714        Err(e) => {
1715            eprintln!(
1716                "spg-embedded: cold-segment scan skip {}: read failed: {e}",
1717                path.display()
1718            );
1719            return false;
1720        }
1721    };
1722    let mut new_cat = engine.catalog().clone();
1723    if let Err(e) = new_cat.load_segment_bytes_at(segment_id, bytes) {
1724        eprintln!(
1725            "spg-embedded: cold-segment scan skip {}: parse/load failed: {e}",
1726            path.display()
1727        );
1728        return false;
1729    }
1730    engine.replace_catalog(new_cat);
1731    true
1732}
1733
1734/// CoW-4 (v7.34) — D10 + missing-manifest fallback: scan
1735/// `<db>.spg/segments/` for `seg_<id>.spg` files and attach any that
1736/// aren't already in `cold_segment_paths`. Closes the window where a
1737/// crash between snapshot rename and manifest rename leaves
1738/// post-checkpoint cold segments orphaned on disk (the snapshot's CRC
1739/// no longer matches the stale manifest, so the manifest path
1740/// silently dropped them). The segment parser self-verifies, so a
1741/// torn write surfaces as a per-segment skip, never silent corruption.
1742fn scan_cold_segments_dir(
1743    segments_dir: &Path,
1744    engine: &mut Engine,
1745    cold_segment_paths: &mut BTreeMap<u32, PathBuf>,
1746) {
1747    // v7.34.1 (mailrs prod report bug A): single-file catalogs (e.g.
1748    // `/data/spg/mailrs.spg` is a regular file, not the `<db>/<db>.spg`
1749    // layout this scan assumes) make the computed `<db>.spg/segments`
1750    // path traverse a file inode, which surfaces as ENOTDIR (`Not a
1751    // directory`, errno 20). Treat any non-directory state — absent,
1752    // file-in-the-way, stat-blocked — as "no segments to scan" and
1753    // silently return. The eprintln below only fires for the genuine
1754    // mid-walk read errors (permission flip, IO failure) that operators
1755    // need to see.
1756    if !segments_dir.is_dir() {
1757        return;
1758    }
1759    let read_dir = match std::fs::read_dir(segments_dir) {
1760        Ok(rd) => rd,
1761        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
1762        Err(e) => {
1763            eprintln!(
1764                "spg-embedded: cold-segment scan: cannot read {}: {e}",
1765                segments_dir.display()
1766            );
1767            return;
1768        }
1769    };
1770    for entry in read_dir.flatten() {
1771        let path = entry.path();
1772        // Only the canonical `seg_<id>.spg` form. `.tmp` half-renames
1773        // and unknown extensions are skipped — the segment writer's
1774        // tmp+rename pattern guarantees `.spg` files are either fully
1775        // written or absent.
1776        if path.extension().and_then(|s| s.to_str()) != Some("spg") {
1777            continue;
1778        }
1779        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
1780            continue;
1781        };
1782        let Some(id_str) = stem.strip_prefix("seg_") else {
1783            continue;
1784        };
1785        let Ok(segment_id) = id_str.parse::<u32>() else {
1786            continue;
1787        };
1788        if cold_segment_paths.contains_key(&segment_id) {
1789            continue;
1790        }
1791        if attach_segment_from_disk(engine, segment_id, &path) {
1792            cold_segment_paths.insert(segment_id, path);
1793        }
1794    }
1795}
1796
1797/// v7.19 — list every `.wal` file in `wal_dir` in
1798/// lexicographic order (which doubles as chunk-creation
1799/// order thanks to the zero-padded filename format).
1800fn sorted_wal_chunks(wal_dir: &Path) -> std::io::Result<Vec<PathBuf>> {
1801    let mut paths = Vec::new();
1802    let read_dir = match std::fs::read_dir(wal_dir) {
1803        Ok(rd) => rd,
1804        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(paths),
1805        Err(e) => return Err(e),
1806    };
1807    for entry in read_dir {
1808        let entry = entry?;
1809        let path = entry.path();
1810        if path.extension().and_then(|s| s.to_str()) == Some("wal") {
1811            paths.push(path);
1812        }
1813    }
1814    paths.sort();
1815    Ok(paths)
1816}
1817
1818/// v7.18 PITR — encode one v4 `checkpoint_marker` record. Layout:
1819///
1820/// ```text
1821/// [u32 LE (payload_len | WAL_V2_SENTINEL | WAL_V3_FLAG)]
1822/// [u32 LE crc32 over (type_byte || payload)]
1823/// [u8  type = 0x11]
1824/// payload:
1825///   [u64 LE checkpoint_lsn]
1826///   [i64 LE checkpoint_unix_us  (WAL_V4_NO_CLOCK if no clock)]
1827///   [u16 LE snapshot_path_len]
1828///   [snapshot_path_bytes]
1829/// ```
1830///
1831/// `payload_len` covers only the payload — keeping the framing
1832/// uniform across v3 / v4 record types so torn-write detection in
1833/// `replay_wal_into_engine` stays trivial.
1834fn encode_v4_checkpoint_marker(
1835    checkpoint_lsn: u64,
1836    checkpoint_unix_us: i64,
1837    snapshot_path: &Path,
1838) -> Vec<u8> {
1839    let snapshot_bytes = snapshot_path.to_string_lossy().into_owned();
1840    let snap_payload = snapshot_bytes.as_bytes();
1841    let snap_len_u16: u16 = snap_payload.len().min(u16::MAX as usize) as u16;
1842    let mut payload = Vec::with_capacity(8 + 8 + 2 + snap_payload.len());
1843    payload.extend_from_slice(&checkpoint_lsn.to_le_bytes());
1844    payload.extend_from_slice(&checkpoint_unix_us.to_le_bytes());
1845    payload.extend_from_slice(&snap_len_u16.to_le_bytes());
1846    payload.extend_from_slice(&snap_payload[..snap_len_u16 as usize]);
1847    let mut crc_buf = Vec::with_capacity(1 + payload.len());
1848    crc_buf.push(WAL_V4_TYPE_CHECKPOINT_MARKER);
1849    crc_buf.extend_from_slice(&payload);
1850    let crc = spg_crypto::crc32::crc32(&crc_buf);
1851    let header = ((payload.len() as u32) | WAL_V2_SENTINEL | WAL_V3_FLAG).to_le_bytes();
1852    let mut out = Vec::with_capacity(4 + 4 + 1 + payload.len());
1853    out.extend_from_slice(&header);
1854    out.extend_from_slice(&crc.to_le_bytes());
1855    out.push(WAL_V4_TYPE_CHECKPOINT_MARKER);
1856    out.extend_from_slice(&payload);
1857    out
1858}
1859
1860/// v7.18 PITR — encode one v4 `auto_commit_sql` record. Layout:
1861///
1862/// ```text
1863/// [u32 LE (sql_len | WAL_V2_SENTINEL | WAL_V3_FLAG)]
1864/// [u32 LE crc32 over (type_byte || lsn || ts || sql_bytes)]
1865/// [u8  type = 0x10]
1866/// [u64 LE commit_lsn]
1867/// [i64 LE commit_unix_us  (= WAL_V4_NO_CLOCK when no ClockFn)]
1868/// [sql bytes]
1869/// ```
1870///
1871/// `sql_len` field stays the SQL byte count — same shape as v3 — so
1872/// replay-buffer torn-write detection compares against
1873/// `WAL_V4_EXTRA_HEADER + sql_len`. v3 records (type 0x01) stay
1874/// readable by the same loop with their original 9-byte header
1875/// arithmetic.
1876fn encode_v4_auto_commit(sql: &str, commit_lsn: u64, commit_unix_us: i64) -> Vec<u8> {
1877    encode_v4_framed(
1878        WAL_V4_TYPE_AUTO_COMMIT_SQL,
1879        sql.as_bytes(),
1880        commit_lsn,
1881        commit_unix_us,
1882    )
1883}
1884
1885/// v7.21 — same envelope, `WAL_V4_TYPE_TX_COMMIT_SQL` type byte.
1886/// `script` = the transaction's statements joined with `";\n"`.
1887fn encode_v4_tx_commit(script: &str, commit_lsn: u64, commit_unix_us: i64) -> Vec<u8> {
1888    encode_v4_framed(
1889        WAL_V4_TYPE_TX_COMMIT_SQL,
1890        script.as_bytes(),
1891        commit_lsn,
1892        commit_unix_us,
1893    )
1894}
1895
1896/// v7.34 (crash-recovery P0 #2) — encode one row-level redo record. Same
1897/// v4 envelope + CRC, type byte 0x13; the payload is the
1898/// `encode_redo_log` bytes (physical changes) instead of SQL text, so
1899/// replay applies them in place of re-executing the statement.
1900fn encode_v5_row_redo(redo_bytes: &[u8], commit_lsn: u64, commit_unix_us: i64) -> Vec<u8> {
1901    encode_v4_framed(WAL_V5_TYPE_ROW_REDO, redo_bytes, commit_lsn, commit_unix_us)
1902}
1903
1904fn encode_v4_framed(
1905    type_byte: u8,
1906    payload: &[u8],
1907    commit_lsn: u64,
1908    commit_unix_us: i64,
1909) -> Vec<u8> {
1910    let mut crc_buf = Vec::with_capacity(1 + WAL_V4_EXTRA_HEADER + payload.len());
1911    crc_buf.push(type_byte);
1912    crc_buf.extend_from_slice(&commit_lsn.to_le_bytes());
1913    crc_buf.extend_from_slice(&commit_unix_us.to_le_bytes());
1914    crc_buf.extend_from_slice(payload);
1915    let crc = spg_crypto::crc32::crc32(&crc_buf);
1916    let header = ((payload.len() as u32) | WAL_V2_SENTINEL | WAL_V3_FLAG).to_le_bytes();
1917    let mut out = Vec::with_capacity(4 + 4 + 1 + WAL_V4_EXTRA_HEADER + payload.len());
1918    out.extend_from_slice(&header);
1919    out.extend_from_slice(&crc.to_le_bytes());
1920    out.push(type_byte);
1921    out.extend_from_slice(&commit_lsn.to_le_bytes());
1922    out.extend_from_slice(&commit_unix_us.to_le_bytes());
1923    out.extend_from_slice(payload);
1924    out
1925}
1926
1927/// v7.37.13 (A1.6) — cached lookup of `SPG_WAL_HASH`. `crc32c`
1928/// (default) → [`WAL_V6_HASH_SCHEME_CRC32C`]; `blake3` → BLAKE3.
1929/// Any other value falls back to default. Process-wide cache — the
1930/// hash scheme is a wire-format invariant, not a runtime tunable.
1931fn wal_hash_scheme() -> u8 {
1932    #[cfg(test)]
1933    {
1934        let v = WAL_HASH_SCHEME_OVERRIDE.load(std::sync::atomic::Ordering::Acquire);
1935        if v == 0 || v == 1 {
1936            return v as u8;
1937        }
1938    }
1939    static CACHED: std::sync::OnceLock<u8> = std::sync::OnceLock::new();
1940    *CACHED.get_or_init(|| match std::env::var("SPG_WAL_HASH").as_deref() {
1941        Ok("blake3") => WAL_V6_HASH_SCHEME_BLAKE3,
1942        _ => WAL_V6_HASH_SCHEME_CRC32C,
1943    })
1944}
1945
1946/// v7.37.13 (A1.6) — `#[cfg(test)]` override for [`wal_hash_scheme`].
1947///   -1 = use env (default)
1948///    0 = force CRC32C-only
1949///    1 = force BLAKE3
1950#[cfg(test)]
1951pub(crate) static WAL_HASH_SCHEME_OVERRIDE: std::sync::atomic::AtomicI8 =
1952    std::sync::atomic::AtomicI8::new(-1);
1953
1954/// v7.37.13 (A1.2 + A1.3 + A1.6) — encode a v6 WAL record.
1955///
1956/// Layout: see the doc comment on [`WAL_V6_FLAG`].
1957///
1958/// `prev_lsn` is the commit_lsn of the previous v6 record in the
1959/// same chunk (xl_prev equivalent). v7.37.13.5 callers always pass
1960/// 0 — the field is reserved so 13.7 can populate it without
1961/// another format bump.
1962fn encode_v6_framed(
1963    type_byte: u8,
1964    payload: &[u8],
1965    prev_lsn: u64,
1966    commit_lsn: u64,
1967    commit_unix_us: i64,
1968) -> Vec<u8> {
1969    let scheme = wal_hash_scheme();
1970    let blake3_extra = if scheme == WAL_V6_HASH_SCHEME_BLAKE3 {
1971        WAL_V6_BLAKE3_LEN
1972    } else {
1973        0
1974    };
1975    let body_len = 1 /*type*/ + WAL_V6_EXTRA_HEADER + blake3_extra + payload.len();
1976
1977    let mut body = Vec::with_capacity(body_len);
1978    body.push(type_byte);
1979    body.extend_from_slice(&prev_lsn.to_le_bytes());
1980    body.extend_from_slice(&commit_lsn.to_le_bytes());
1981    body.extend_from_slice(&commit_unix_us.to_le_bytes());
1982    body.push(scheme);
1983    if scheme == WAL_V6_HASH_SCHEME_BLAKE3 {
1984        let h = spg_crypto::hash(payload);
1985        body.extend_from_slice(&h);
1986    }
1987    body.extend_from_slice(payload);
1988
1989    let crc = spg_crypto::crc32c::crc32c(&body);
1990    let header =
1991        ((payload.len() as u32) | WAL_V2_SENTINEL | WAL_V3_FLAG | WAL_V6_FLAG).to_le_bytes();
1992
1993    let mut out = Vec::with_capacity(4 + 4 + body_len);
1994    out.extend_from_slice(&header);
1995    out.extend_from_slice(&crc.to_le_bytes());
1996    out.extend_from_slice(&body);
1997    out
1998}
1999
2000/// v7.37.13 (A1.2 / A1.3) — v6 auto-commit SQL record. `prev_lsn`
2001/// is the commit_lsn of the previous v6 record in the same chunk
2002/// (xl_prev equivalent). v7.37.13.7 wires real values from the
2003/// monotonic `commit_lsn` counter; callers in wal_after_ok pass
2004/// `commit_lsn.saturating_sub(1)` since the counter is +1 each
2005/// record. The chain detects a torn boundary where two adjacent
2006/// records' LSNs aren't contiguous.
2007fn encode_v6_auto_commit(
2008    sql: &str,
2009    prev_lsn: u64,
2010    commit_lsn: u64,
2011    commit_unix_us: i64,
2012) -> Vec<u8> {
2013    encode_v6_framed(
2014        WAL_V4_TYPE_AUTO_COMMIT_SQL,
2015        sql.as_bytes(),
2016        prev_lsn,
2017        commit_lsn,
2018        commit_unix_us,
2019    )
2020}
2021
2022fn encode_v6_tx_commit(
2023    script: &str,
2024    prev_lsn: u64,
2025    commit_lsn: u64,
2026    commit_unix_us: i64,
2027) -> Vec<u8> {
2028    encode_v6_framed(
2029        WAL_V4_TYPE_TX_COMMIT_SQL,
2030        script.as_bytes(),
2031        prev_lsn,
2032        commit_lsn,
2033        commit_unix_us,
2034    )
2035}
2036
2037fn encode_v6_row_redo(
2038    redo_bytes: &[u8],
2039    prev_lsn: u64,
2040    commit_lsn: u64,
2041    commit_unix_us: i64,
2042) -> Vec<u8> {
2043    encode_v6_framed(
2044        WAL_V5_TYPE_ROW_REDO,
2045        redo_bytes,
2046        prev_lsn,
2047        commit_lsn,
2048        commit_unix_us,
2049    )
2050}
2051
2052fn encode_v6_checkpoint_marker(
2053    prev_lsn: u64,
2054    commit_lsn: u64,
2055    commit_unix_us: i64,
2056    db_path: &Path,
2057) -> Vec<u8> {
2058    // Same payload as encode_v4_checkpoint_marker: lsn + ts + path.
2059    let path_bytes = db_path.to_string_lossy();
2060    let path_len = path_bytes.len() as u16;
2061    let mut payload = Vec::with_capacity(8 + 8 + 2 + path_bytes.len());
2062    payload.extend_from_slice(&commit_lsn.to_le_bytes());
2063    payload.extend_from_slice(&commit_unix_us.to_le_bytes());
2064    payload.extend_from_slice(&path_len.to_le_bytes());
2065    payload.extend_from_slice(path_bytes.as_bytes());
2066    encode_v6_framed(
2067        WAL_V4_TYPE_CHECKPOINT_MARKER,
2068        &payload,
2069        prev_lsn,
2070        commit_lsn,
2071        commit_unix_us,
2072    )
2073}
2074
2075/// v7.37.13 (A1.2) — parsed view of a v6 record body. Returned by
2076/// [`parse_v6_record_body`] for use by both the replay loop and the
2077/// public `parse_wal_records` iterator. The caller has already
2078/// validated CRC, so all bytes here are trusted.
2079#[derive(Debug)]
2080struct V6RecordView<'a> {
2081    type_byte: u8,
2082    #[allow(dead_code)]
2083    prev_lsn: u64,
2084    commit_lsn: u64,
2085    commit_unix_us: i64,
2086    payload: &'a [u8],
2087}
2088
2089/// v7.37.13 (A1.2 + A1.3 + A1.6) — decode one v6 record from the
2090/// raw byte stream at offset `cur`. On success returns the parsed
2091/// view + the total number of bytes consumed (including the 8-byte
2092/// frame header). Errors on:
2093///   - truncated header / body (short record)
2094///   - CRC mismatch (= corruption / bit flip)
2095///   - unknown hash_scheme
2096///   - BLAKE3 mismatch (if scheme=BLAKE3 and payload digest differs)
2097fn parse_v6_record_body<'a>(
2098    wal_bytes: &'a [u8],
2099    cur: usize,
2100    rec_len: usize,
2101) -> Result<(V6RecordView<'a>, usize), String> {
2102    // After the 4-byte length header and 4-byte CRC:
2103    //   1 type + 8 prev + 8 lsn + 8 ts + 1 scheme + [32 blake3] + payload
2104    let min_body = 1 + WAL_V6_EXTRA_HEADER + rec_len;
2105    if wal_bytes.len() < cur + 4 + 4 + min_body {
2106        return Err(format!(
2107            "WAL parse: v6 record at offset {cur} truncated header"
2108        ));
2109    }
2110    let stored_crc = u32::from_le_bytes(wal_bytes[cur + 4..cur + 8].try_into().unwrap());
2111    let body_start = cur + 8;
2112    let type_byte = wal_bytes[body_start];
2113    let prev_lsn = u64::from_le_bytes(
2114        wal_bytes[body_start + 1..body_start + 9]
2115            .try_into()
2116            .unwrap(),
2117    );
2118    let commit_lsn = u64::from_le_bytes(
2119        wal_bytes[body_start + 9..body_start + 17]
2120            .try_into()
2121            .unwrap(),
2122    );
2123    let commit_unix_us = i64::from_le_bytes(
2124        wal_bytes[body_start + 17..body_start + 25]
2125            .try_into()
2126            .unwrap(),
2127    );
2128    let scheme = wal_bytes[body_start + 25];
2129    let (blake3_extra, blake3_slice) = if scheme == WAL_V6_HASH_SCHEME_BLAKE3 {
2130        let start = body_start + 26;
2131        let end = start + WAL_V6_BLAKE3_LEN;
2132        if wal_bytes.len() < end + rec_len {
2133            return Err(format!(
2134                "WAL parse: v6 BLAKE3 record at offset {cur} truncated"
2135            ));
2136        }
2137        (WAL_V6_BLAKE3_LEN, Some(&wal_bytes[start..end]))
2138    } else if scheme == WAL_V6_HASH_SCHEME_CRC32C {
2139        (0usize, None)
2140    } else {
2141        return Err(format!(
2142            "WAL parse: v6 record at offset {cur} has unknown hash_scheme {scheme:#04x}"
2143        ));
2144    };
2145    let payload_start = body_start + 26 + blake3_extra;
2146    let payload_end = payload_start + rec_len;
2147    if wal_bytes.len() < payload_end {
2148        return Err(format!(
2149            "WAL parse: v6 record at offset {cur} truncated payload"
2150        ));
2151    }
2152    let body_end = payload_end;
2153    let body = &wal_bytes[body_start..body_end];
2154    let computed_crc = spg_crypto::crc32c::crc32c(body);
2155    if computed_crc != stored_crc {
2156        return Err(format!(
2157            "WAL parse: v6 CRC32C mismatch at offset {cur} (stored {stored_crc:#010x}, \
2158             computed {computed_crc:#010x}) — record corrupted by bit flip or torn write"
2159        ));
2160    }
2161    let payload = &wal_bytes[payload_start..payload_end];
2162    if let Some(blake3_bytes) = blake3_slice {
2163        let computed_b3 = spg_crypto::hash(payload);
2164        if computed_b3.as_slice() != blake3_bytes {
2165            return Err(format!(
2166                "WAL parse: v6 BLAKE3 mismatch at offset {cur} (payload digest differs)"
2167            ));
2168        }
2169    }
2170    let total = 4 + 4 + 1 + WAL_V6_EXTRA_HEADER + blake3_extra + rec_len;
2171    Ok((
2172        V6RecordView {
2173            type_byte,
2174            prev_lsn,
2175            commit_lsn,
2176            commit_unix_us,
2177            payload,
2178        },
2179        total,
2180    ))
2181}
2182
2183/// v7.1 — decode + apply every record in `wal_bytes` to `engine`.
2184/// Returns the count of records successfully applied. A truncated
2185/// trailing record (mid-write torn) is dropped silently — the
2186/// same recovery story `spg-server`'s boot path uses.
2187fn replay_wal_into_engine(wal_bytes: &[u8], engine: &mut Engine) -> Result<usize, String> {
2188    let mut applied = 0usize;
2189    let mut cur = 0usize;
2190    while cur < wal_bytes.len() {
2191        if wal_bytes.len() - cur < 4 {
2192            // Trailing partial header — torn write, drop and stop.
2193            break;
2194        }
2195        let raw_len = u32::from_le_bytes(wal_bytes[cur..cur + 4].try_into().unwrap());
2196        let is_v2 = raw_len & WAL_V2_SENTINEL != 0;
2197        let is_v3 = is_v2 && (raw_len & WAL_V3_FLAG != 0);
2198        let is_v6 = is_v3 && (raw_len & WAL_V6_FLAG != 0);
2199        // v7.37.13 (A1.2) — v6 records mask out the V6_FLAG bit too.
2200        if is_v6 {
2201            let len_mask = !(WAL_V2_SENTINEL | WAL_V3_FLAG | WAL_V6_FLAG);
2202            let rec_len = (raw_len & len_mask) as usize;
2203            match parse_v6_record_body(wal_bytes, cur, rec_len) {
2204                Err(e) => return Err(e),
2205                Ok((view, total)) => {
2206                    match view.type_byte {
2207                        WAL_V4_TYPE_CHECKPOINT_MARKER => {
2208                            // checkpoint anchor — skip on replay
2209                        }
2210                        WAL_V5_TYPE_ROW_REDO => {
2211                            let changes =
2212                                spg_storage::decode_redo_log(view.payload).map_err(|e| {
2213                                    format!("WAL replay: v6 redo decode at offset {cur}: {e:?}")
2214                                })?;
2215                            engine.apply_redo(&changes).map_err(|e| {
2216                                format!("WAL replay: v6 apply_redo at offset {cur}: {e:?}")
2217                            })?;
2218                            applied += 1;
2219                        }
2220                        WAL_V4_TYPE_AUTO_COMMIT_SQL | WAL_V4_TYPE_TX_COMMIT_SQL => {
2221                            let sql = std::str::from_utf8(view.payload).map_err(|e| {
2222                                format!("WAL replay: v6 non-UTF-8 SQL at offset {cur}: {e}")
2223                            })?;
2224                            for stmt in split_statements(sql) {
2225                                engine.execute(stmt).map_err(|e| {
2226                                    format!(
2227                                        "WAL replay: v6 apply {stmt:?} at offset {cur} rejected: {e:?}"
2228                                    )
2229                                })?;
2230                            }
2231                            applied += 1;
2232                        }
2233                        other => {
2234                            return Err(format!(
2235                                "WAL replay: v6 unknown type byte {other:#04x} at offset {cur}"
2236                            ));
2237                        }
2238                    }
2239                    // Silence dead_code on view.prev_lsn / commit_lsn /
2240                    // commit_unix_us — they're surfaced for 13.7 +
2241                    // PITR tooling, not used in basic replay yet.
2242                    let _ = (view.prev_lsn, view.commit_lsn, view.commit_unix_us);
2243                    cur += total;
2244                    continue;
2245                }
2246            }
2247        }
2248        let len_mask = if is_v3 {
2249            !(WAL_V2_SENTINEL | WAL_V3_FLAG)
2250        } else {
2251            !WAL_V2_SENTINEL
2252        };
2253        let rec_len = (raw_len & len_mask) as usize;
2254        let header_len = if is_v3 {
2255            9
2256        } else if is_v2 {
2257            8
2258        } else {
2259            4
2260        };
2261        if wal_bytes.len() - cur < header_len + rec_len {
2262            // Torn record at the tail — drop, stop.
2263            break;
2264        }
2265        if is_v3 {
2266            let type_byte = wal_bytes[cur + 8];
2267            match type_byte {
2268                WAL_V3_TYPE_AUTO_COMMIT_SQL => {}
2269                WAL_V3_TYPE_DURABILITY_CHECKPOINT => {
2270                    // durability_checkpoint marker — skip, no SQL.
2271                    cur += header_len + rec_len;
2272                    continue;
2273                }
2274                WAL_V4_TYPE_CHECKPOINT_MARKER => {
2275                    // v7.18 PITR — checkpoint anchor, skip on replay
2276                    // (engine state past this point reflects the
2277                    // matching snapshot already loaded by the caller).
2278                    cur += header_len + rec_len;
2279                    continue;
2280                }
2281                WAL_V4_TYPE_AUTO_COMMIT_SQL | WAL_V4_TYPE_TX_COMMIT_SQL => {
2282                    // v7.18 PITR — v4 record carries 16 bytes of
2283                    // (commit_lsn, commit_unix_us) between the type
2284                    // byte and the SQL payload. Replay reads them but
2285                    // does not enforce them — the engine doesn't
2286                    // surface LSN/clock here. Restore tooling
2287                    // (spgctl) parses them via parse_wal_record below.
2288                    //
2289                    // v7.21 — tx-commit records (0x12) carry a whole
2290                    // transaction as a `";\n"`-joined script;
2291                    // split_statements is a no-op on the single-
2292                    // statement auto-commit form.
2293                    let v4_total = header_len + WAL_V4_EXTRA_HEADER + rec_len;
2294                    if wal_bytes.len() - cur < v4_total {
2295                        // Torn v4 record at the tail — drop, stop.
2296                        break;
2297                    }
2298                    let sql_start = cur + header_len + WAL_V4_EXTRA_HEADER;
2299                    let sql_bytes = &wal_bytes[sql_start..sql_start + rec_len];
2300                    let sql = std::str::from_utf8(sql_bytes)
2301                        .map_err(|e| format!("WAL replay: non-UTF-8 SQL at offset {cur}: {e}"))?;
2302                    for stmt in split_statements(sql) {
2303                        engine.execute(stmt).map_err(|e| {
2304                            format!("WAL replay: apply {stmt:?} at offset {cur} rejected: {e:?}")
2305                        })?;
2306                    }
2307                    applied += 1;
2308                    cur += v4_total;
2309                    continue;
2310                }
2311                other => {
2312                    return Err(format!(
2313                        "WAL replay: unknown v3 type byte {other:#04x} at offset {cur}"
2314                    ));
2315                }
2316            }
2317        }
2318        let sql_bytes = &wal_bytes[cur + header_len..cur + header_len + rec_len];
2319        let sql = std::str::from_utf8(sql_bytes)
2320            .map_err(|e| format!("WAL replay: non-UTF-8 SQL at offset {cur}: {e}"))?;
2321        engine
2322            .execute(sql)
2323            .map_err(|e| format!("WAL replay: apply {sql:?} at offset {cur} rejected: {e:?}"))?;
2324        applied += 1;
2325        cur += header_len + rec_len;
2326    }
2327    Ok(applied)
2328}
2329
2330/// v7.18 PITR — parsed WAL record, surfaced for restore / verify
2331/// tooling. The replay loop above doesn't expose LSN/timestamp;
2332/// `spgctl restore --to <timestamp>` and `spgctl verify` need them.
2333/// Returned offsets are byte-positions inside the WAL buffer.
2334#[derive(Debug, Clone)]
2335pub struct WalRecord<'a> {
2336    /// Byte offset in the WAL buffer where this record starts.
2337    pub offset: usize,
2338    /// Type byte (0x01 = v3 auto-commit, 0x10 = v4 auto-commit,
2339    /// 0x02 = durability checkpoint marker).
2340    pub type_byte: u8,
2341    /// `Some(lsn)` for v4 records, `None` for v3.
2342    pub commit_lsn: Option<u64>,
2343    /// `Some(unix_us)` for v4 records carrying a clock-set timestamp,
2344    /// `None` for v3 or for v4 records explicitly written with
2345    /// `WAL_V4_NO_CLOCK` (sentinel for "no ClockFn at commit time").
2346    pub commit_unix_us: Option<i64>,
2347    /// v7.37.13 (A1.3) — `Some(prev_lsn)` for v6 records (xl_prev
2348    /// equivalent — the commit_lsn of the previous v6 record in
2349    /// this chunk). `None` for v3 / v4 / v5 records, which do not
2350    /// carry a prev-link.
2351    pub prev_lsn: Option<u64>,
2352    /// SQL payload as borrowed bytes. Empty for durability markers.
2353    pub sql: &'a [u8],
2354}
2355
2356/// v7.18 PITR — iterate over `wal_bytes` yielding one `WalRecord`
2357/// per intact record. Torn-tail records terminate iteration
2358/// silently (same recovery story as `replay_wal_into_engine`).
2359/// Unknown type bytes inside a v3 envelope return `Err` so the
2360/// caller knows the WAL was written by a newer SPG.
2361pub fn parse_wal_records(wal_bytes: &[u8]) -> Result<Vec<WalRecord<'_>>, String> {
2362    let mut out = Vec::new();
2363    let mut cur = 0usize;
2364    while cur < wal_bytes.len() {
2365        if wal_bytes.len() - cur < 4 {
2366            break;
2367        }
2368        let raw_len = u32::from_le_bytes(wal_bytes[cur..cur + 4].try_into().unwrap());
2369        let is_v2 = raw_len & WAL_V2_SENTINEL != 0;
2370        let is_v3 = is_v2 && (raw_len & WAL_V3_FLAG != 0);
2371        let is_v6 = is_v3 && (raw_len & WAL_V6_FLAG != 0);
2372        // v7.37.13 (A1.2) — v6 envelope: parser dispatches on
2373        // V6_FLAG before falling through to the v3/v4/v5 paths.
2374        if is_v6 {
2375            let len_mask = !(WAL_V2_SENTINEL | WAL_V3_FLAG | WAL_V6_FLAG);
2376            let rec_len = (raw_len & len_mask) as usize;
2377            match parse_v6_record_body(wal_bytes, cur, rec_len) {
2378                Err(_) => {
2379                    // Torn / corrupt tail — same recovery story as
2380                    // the v3/v4/v5 paths: stop iterating, the caller
2381                    // sees an intact prefix.
2382                    break;
2383                }
2384                Ok((view, total)) => {
2385                    out.push(WalRecord {
2386                        offset: cur,
2387                        type_byte: view.type_byte,
2388                        commit_lsn: Some(view.commit_lsn),
2389                        commit_unix_us: if view.commit_unix_us == WAL_V4_NO_CLOCK {
2390                            None
2391                        } else {
2392                            Some(view.commit_unix_us)
2393                        },
2394                        prev_lsn: Some(view.prev_lsn),
2395                        sql: view.payload,
2396                    });
2397                    cur += total;
2398                    continue;
2399                }
2400            }
2401        }
2402        let len_mask = if is_v3 {
2403            !(WAL_V2_SENTINEL | WAL_V3_FLAG)
2404        } else {
2405            !WAL_V2_SENTINEL
2406        };
2407        let rec_len = (raw_len & len_mask) as usize;
2408        let header_len = if is_v3 {
2409            9
2410        } else if is_v2 {
2411            8
2412        } else {
2413            4
2414        };
2415        if wal_bytes.len() - cur < header_len + rec_len {
2416            break;
2417        }
2418        if !is_v3 {
2419            // v1 / v2 records carry no type byte; treat as legacy
2420            // auto-commit SQL with no LSN/time.
2421            let sql = &wal_bytes[cur + header_len..cur + header_len + rec_len];
2422            out.push(WalRecord {
2423                offset: cur,
2424                type_byte: WAL_V3_TYPE_AUTO_COMMIT_SQL,
2425                commit_lsn: None,
2426                commit_unix_us: None,
2427                prev_lsn: None,
2428                sql,
2429            });
2430            cur += header_len + rec_len;
2431            continue;
2432        }
2433        let type_byte = wal_bytes[cur + 8];
2434        match type_byte {
2435            WAL_V3_TYPE_AUTO_COMMIT_SQL => {
2436                let sql = &wal_bytes[cur + header_len..cur + header_len + rec_len];
2437                out.push(WalRecord {
2438                    offset: cur,
2439                    type_byte,
2440                    commit_lsn: None,
2441                    commit_unix_us: None,
2442                    prev_lsn: None,
2443                    sql,
2444                });
2445                cur += header_len + rec_len;
2446            }
2447            WAL_V3_TYPE_DURABILITY_CHECKPOINT => {
2448                out.push(WalRecord {
2449                    offset: cur,
2450                    type_byte,
2451                    commit_lsn: None,
2452                    commit_unix_us: None,
2453                    prev_lsn: None,
2454                    sql: &[],
2455                });
2456                cur += header_len + rec_len;
2457            }
2458            WAL_V4_TYPE_CHECKPOINT_MARKER => {
2459                // v7.18 PITR — payload = (lsn u64)(ts i64)(path_len u16)(path bytes).
2460                // We surface lsn + ts on the WalRecord; the path lives
2461                // in `sql` since the type byte already disambiguates
2462                // record meaning and adding a dedicated field would
2463                // bloat the iterator return type for every variant.
2464                if rec_len < 18 {
2465                    return Err(format!(
2466                        "WAL parse: checkpoint marker at offset {cur} too short ({rec_len} bytes)"
2467                    ));
2468                }
2469                let lsn = u64::from_le_bytes(
2470                    wal_bytes[cur + header_len..cur + header_len + 8]
2471                        .try_into()
2472                        .unwrap(),
2473                );
2474                let ts_raw = i64::from_le_bytes(
2475                    wal_bytes[cur + header_len + 8..cur + header_len + 16]
2476                        .try_into()
2477                        .unwrap(),
2478                );
2479                let path_len = u16::from_le_bytes(
2480                    wal_bytes[cur + header_len + 16..cur + header_len + 18]
2481                        .try_into()
2482                        .unwrap(),
2483                ) as usize;
2484                if rec_len < 18 + path_len {
2485                    return Err(format!(
2486                        "WAL parse: checkpoint marker at offset {cur} truncated path"
2487                    ));
2488                }
2489                let path_start = cur + header_len + 18;
2490                let path_bytes = &wal_bytes[path_start..path_start + path_len];
2491                let commit_unix_us = if ts_raw == WAL_V4_NO_CLOCK {
2492                    None
2493                } else {
2494                    Some(ts_raw)
2495                };
2496                out.push(WalRecord {
2497                    offset: cur,
2498                    type_byte,
2499                    commit_lsn: Some(lsn),
2500                    commit_unix_us,
2501                    prev_lsn: None,
2502                    sql: path_bytes,
2503                });
2504                cur += header_len + rec_len;
2505            }
2506            WAL_V4_TYPE_AUTO_COMMIT_SQL | WAL_V4_TYPE_TX_COMMIT_SQL | WAL_V5_TYPE_ROW_REDO => {
2507                let v4_total = header_len + WAL_V4_EXTRA_HEADER + rec_len;
2508                if wal_bytes.len() - cur < v4_total {
2509                    break;
2510                }
2511                let lsn = u64::from_le_bytes(
2512                    wal_bytes[cur + header_len..cur + header_len + 8]
2513                        .try_into()
2514                        .unwrap(),
2515                );
2516                let ts_raw = i64::from_le_bytes(
2517                    wal_bytes[cur + header_len + 8..cur + header_len + 16]
2518                        .try_into()
2519                        .unwrap(),
2520                );
2521                let commit_unix_us = if ts_raw == WAL_V4_NO_CLOCK {
2522                    None
2523                } else {
2524                    Some(ts_raw)
2525                };
2526                let sql_start = cur + header_len + WAL_V4_EXTRA_HEADER;
2527                let sql = &wal_bytes[sql_start..sql_start + rec_len];
2528                out.push(WalRecord {
2529                    offset: cur,
2530                    type_byte,
2531                    commit_lsn: Some(lsn),
2532                    commit_unix_us,
2533                    prev_lsn: None,
2534                    sql,
2535                });
2536                cur += v4_total;
2537            }
2538            other => {
2539                return Err(format!(
2540                    "WAL parse: unknown type byte {other:#04x} at offset {cur}"
2541                ));
2542            }
2543        }
2544    }
2545    Ok(out)
2546}
2547
2548/// v7.1 — predicate for "should the next `execute()` mutate the
2549/// WAL?" Returns `false` for SELECT / SHOW / EXPLAIN / BEGIN /
2550/// COMMIT / ROLLBACK and the SPG-specific verbs that don't go
2551/// through the auto-commit record path on the server (CHECKPOINT,
2552/// COMPACT). Conservative: anything we don't explicitly know is
2553/// read-only falls through to "write a WAL record".
2554fn sql_is_read_only(sql: &str) -> bool {
2555    let t = sql.trim_start();
2556    let head = t
2557        .split(|c: char| c.is_whitespace() || c == ';' || c == '(')
2558        .next()
2559        .unwrap_or("");
2560    matches!(
2561        head.to_ascii_lowercase().as_str(),
2562        "select"
2563            | "show"
2564            | "explain"
2565            | "begin"
2566            | "commit"
2567            | "rollback"
2568            | "checkpoint"
2569            | "compact"
2570            | "wait"
2571            | "with"
2572    )
2573}
2574
2575/// v7.39 (round 180) — does this SQL text mutate data even though its
2576/// head word looks read-shaped or its result is `Rows`? Two callers:
2577///   * a DML with RETURNING answers `Rows` (not CommandOk), so the
2578///     `modified_catalog` gate alone would skip its WAL record;
2579///   * a writable CTE (`WITH … INSERT/UPDATE/DELETE/MERGE`) starts
2580///     with `with`, which [`sql_is_read_only`] classifies as a read.
2581/// Both were silent durability losses on the embedded autocommit and
2582/// in-tx buffer paths (server twin fixed in r178). The WITH arm is a
2583/// conservative substring probe — a false positive only adds a
2584/// harmless replayed statement to the WAL.
2585fn sql_is_dmlish(sql: &str) -> bool {
2586    let head = sql
2587        .trim_start()
2588        .split(|c: char| c.is_whitespace() || c == ';' || c == '(')
2589        .next()
2590        .unwrap_or("")
2591        .to_ascii_lowercase();
2592    match head.as_str() {
2593        "insert" | "update" | "delete" | "merge" => true,
2594        "with" => {
2595            let lower = sql.to_ascii_lowercase();
2596            ["insert", "update", "delete", "merge"]
2597                .iter()
2598                .any(|kw| lower.contains(kw))
2599        }
2600        _ => false,
2601    }
2602}
2603
2604/// v7.39 (round 180) — should this statement's outcome reach the WAL
2605/// on the autocommit path? CommandOk carries the engine's own
2606/// `modified_catalog` verdict; a `Rows` outcome persists iff the SQL
2607/// is DML-shaped (RETURNING).
2608fn wal_worthy(result: &QueryResult, sql: &str) -> bool {
2609    match result {
2610        QueryResult::CommandOk {
2611            modified_catalog, ..
2612        } => *modified_catalog,
2613        QueryResult::Rows { .. } => sql_is_dmlish(sql),
2614        _ => false,
2615    }
2616}
2617
2618/// v7.37 Epic Du — is this a bare `CHECKPOINT` statement? The
2619/// parser accepts `CHECKPOINT` as a top-level statement (a no-op
2620/// at the engine layer — the no_std engine owns no WAL / snapshot),
2621/// so the host recognises it here and forces a real, synchronous
2622/// checkpoint (durability barrier) via [`Database::checkpoint`].
2623/// Head-word match mirrors [`sql_is_read_only`]: only fires when
2624/// the first token is `checkpoint`, so a table / column literally
2625/// named `checkpoint` inside another statement is unaffected.
2626/// v7.39 (round 249) — cheap head-word sniff so only `COPY …`
2627/// statements pay the extra host-side parse in [`Database::execute`].
2628fn sql_head_is_copy(sql: &str) -> bool {
2629    sql.trim_start()
2630        .get(..4)
2631        .is_some_and(|h| h.eq_ignore_ascii_case("copy"))
2632}
2633
2634fn sql_is_checkpoint(sql: &str) -> bool {
2635    let head = sql
2636        .trim_start()
2637        .split(|c: char| c.is_whitespace() || c == ';' || c == '(')
2638        .next()
2639        .unwrap_or("");
2640    head.eq_ignore_ascii_case("checkpoint")
2641}
2642
2643/// Embedded SPG database handle. Owns an `Engine` + provides
2644/// ergonomic wrappers around `execute` and `query`. Drops the
2645/// engine on `Drop` — no WAL flush / fsync, because v6.10.3
2646/// is in-memory only.
2647#[derive(Debug)]
2648pub struct Database {
2649    engine: Engine,
2650    /// v7.1 — persistence sidecar. When `Some(p)`, every
2651    /// `execute(sql)` that mutates state appends a v4
2652    /// `auto_commit_sql` WAL record + fsyncs before the call
2653    /// returns; `Drop` writes a final catalog snapshot to
2654    /// `<db_path>` so the next session boots from a clean
2655    /// snapshot + an empty WAL. `None` = in-memory only (the
2656    /// v6.10.3 shape).
2657    persistence: Option<PersistenceCtx>,
2658    /// v7.18 PITR — monotonic per-database commit LSN. Increments
2659    /// before each successful WAL append; bootstrapped at
2660    /// open_path from `max(parse_wal_records → commit_lsn)` so
2661    /// reopen never reuses an LSN. In-memory databases start at
2662    /// 0 and never advance (no WAL = no LSN-meaningful records).
2663    commit_lsn: AtomicU64,
2664    /// v7.21 (round-12 polish) — explicit-transaction WAL buffer.
2665    /// `Some` between an engine-accepted BEGIN and its
2666    /// COMMIT / ROLLBACK on a persistent database. In-transaction
2667    /// mutations only touch the engine's shadow catalog and report
2668    /// `modified_catalog: false`, so the per-statement auto-commit
2669    /// append never fires for them; their bind-final SQL collects
2670    /// here instead and COMMIT flushes the lot as ONE atomic
2671    /// `WAL_V4_TYPE_TX_COMMIT_SQL` record (ROLLBACK just drops it).
2672    /// Always `None` for in-memory databases.
2673    tx_wal: Option<TxWalBuffer>,
2674    /// v7.37.14 (A2.2 [PG+]) — count of user threads currently
2675    /// holding the engine lock (i.e. mid-execute). Background
2676    /// tasks (freezer / flusher) read this BEFORE attempting to
2677    /// acquire the db Mutex so they can back off when foreground
2678    /// queries are in flight — analogous to PG's
2679    /// `autovacuum_vacuum_cost_delay`, but instead of a fixed
2680    /// delay the SPG variant adapts to live contention.
2681    ///
2682    /// Atomic so background threads can read without going
2683    /// through the lock. Incremented on entry to every Database
2684    /// mutating path (execute / execute_buffered) and decremented
2685    /// on exit via an RAII guard so panics don't leak the
2686    /// counter.
2687    pub(crate) active_query_count: Arc<core::sync::atomic::AtomicU32>,
2688}
2689
2690/// See [`Database::tx_wal`].
2691#[derive(Debug, Default)]
2692struct TxWalBuffer {
2693    /// Bind-final SQL of every non-read-only statement the engine
2694    /// accepted inside the open transaction, in execution order.
2695    statements: Vec<String>,
2696    /// `(savepoint_name, statements.len() at SAVEPOINT time)` —
2697    /// `ROLLBACK TO SAVEPOINT` truncates `statements` back to the
2698    /// recorded mark so the WAL record matches what the engine
2699    /// keeps. PG name-reuse semantics (latest wins).
2700    savepoints: Vec<(String, usize)>,
2701}
2702
2703/// Statement-level transaction-control classification for the WAL
2704/// buffer. Runs AFTER the engine accepted the statement, so the
2705/// engine stays the single validator — this only mirrors state.
2706enum TxControl {
2707    Begin,
2708    Commit,
2709    Rollback,
2710    RollbackToSavepoint(String),
2711    Savepoint(String),
2712    ReleaseSavepoint,
2713}
2714
2715fn tx_control_kind(sql: &str) -> Option<TxControl> {
2716    let mut words = sql
2717        .split(|c: char| c.is_whitespace() || c == ';')
2718        .filter(|w| !w.is_empty())
2719        .map(str::to_ascii_lowercase);
2720    let head = words.next()?;
2721    match head.as_str() {
2722        "begin" | "start" => Some(TxControl::Begin),
2723        "commit" | "end" => Some(TxControl::Commit),
2724        "savepoint" => words.next().map(TxControl::Savepoint),
2725        "release" => Some(TxControl::ReleaseSavepoint),
2726        "rollback" => match words.next().as_deref() {
2727            // ROLLBACK TO [SAVEPOINT] <name>
2728            Some("to") => {
2729                let next = words.next()?;
2730                let name = if next == "savepoint" {
2731                    words.next()?
2732                } else {
2733                    next
2734                };
2735                Some(TxControl::RollbackToSavepoint(name))
2736            }
2737            _ => Some(TxControl::Rollback),
2738        },
2739        _ => None,
2740    }
2741}
2742
2743#[derive(Debug)]
2744#[allow(dead_code)] // `wal_dir`/`current_chunk_path` are read at boot; kept for Drop/diag introspection.
2745struct PersistenceCtx {
2746    db_path: PathBuf,
2747    /// v7.19 — WAL chunk directory at `<db_path>.wal/`.
2748    /// Replaces the v7.18 single-file `<db_path>.wal` layout.
2749    /// Each chunk file inside is named
2750    /// `<unix_us>_<leading_lsn>.wal` (zero-padded to 16 digits
2751    /// so default-lex sort = LSN order).
2752    wal_dir: PathBuf,
2753    /// Path of the currently-open chunk file inside `wal_dir`.
2754    /// Rotated at checkpoint and whenever the chunk crosses
2755    /// `checkpoint_threshold_bytes`. CoW-2 (v7.34) wraps it in
2756    /// `Arc<Mutex<…>>` because the background-checkpoint worker
2757    /// performs the rotation; this struct keeps a clone so Drop /
2758    /// diag introspection still see the live path.
2759    current_chunk_path: Arc<Mutex<PathBuf>>,
2760    /// v7.19 P3 — retention sweeper handle. `Some` when
2761    /// `SPG_PITR_RETENTION_HOURS > 0` at open_path time; `None`
2762    /// when retention is disabled (the default; v7.18 behaviour
2763    /// preserved). The thread polls `wal_dir` every
2764    /// `SPG_PITR_RETENTION_CHECK_SEC` seconds, archives via
2765    /// `SPG_PITR_ARCHIVE_CMD` if set, then deletes chunks older
2766    /// than the retention window. Signalled to exit via
2767    /// `retention_shutdown` on Drop.
2768    retention_shutdown: Option<Arc<AtomicBool>>,
2769    retention_thread: Option<std::thread::JoinHandle<()>>,
2770    /// v7.20 — background WAL flusher for
2771    /// `SPG_SYNCHRONOUS_COMMIT=off`. `None` in the default
2772    /// synchronous mode. Flushes the pending batch every
2773    /// `SPG_WAL_WRITER_DELAY_MS`; signalled + joined on Drop
2774    /// before the final checkpoint so clean shutdown never
2775    /// loses confirmed commits.
2776    flusher_shutdown: Option<Arc<AtomicBool>>,
2777    flusher_thread: Option<std::thread::JoinHandle<()>>,
2778    /// v7.20 P2 — group-commit WAL. Shared with WalTickets
2779    /// returned by the buffered write path so `wait()` can run
2780    /// after the engine write lock is released.
2781    wal: Arc<WalGroup>,
2782    checkpoint_threshold_bytes: u64,
2783    /// v7.37.13 (A1.8 [PG+]) — when true, `checkpoint_threshold_bytes`
2784    /// is recomputed after each checkpoint to track recent WAL growth
2785    /// rate (EWMA, target ~30 s of writes). When the operator pins
2786    /// the threshold via `SPG_EMBEDDED_CHECKPOINT_BYTES` we honour
2787    /// that and disable adaptivity.
2788    ///
2789    /// PG-equivalent: this fills the same role as PG's
2790    /// `checkpoint_completion_target` + `max_wal_size` interplay,
2791    /// but lets the runtime tune the absolute threshold from observed
2792    /// rate rather than requiring the operator to guess.
2793    adaptive_threshold_enabled: bool,
2794    /// v7.37.13 (A1.8) — EWMA of WAL bytes/second across recent
2795    /// checkpoint windows. Updated when a write triggers a
2796    /// checkpoint (caller-side time / bytes path). Initialised to 0
2797    /// (= "no data yet, hold the default").
2798    ewma_wal_rate_bytes_per_sec: Mutex<u64>,
2799    /// v7.37.13 (A1.9) — checkpoint timing + percentile sink.
2800    /// Shared with the checkpoint worker via CheckpointJob.stats
2801    /// so the worker can publish per-job stats without going
2802    /// through the Database write lock.
2803    checkpoint_stats: Arc<Mutex<CheckpointStats>>,
2804    /// v7.37.10 (mailrs 06-23 cascade 7 P0 §"base catalog 17h 没更新")
2805    /// — time-based auto-checkpoint floor. The byte-threshold path
2806    /// (`checkpoint_threshold_bytes`, default 4 MiB) doesn't fire if
2807    /// the workload's WAL growth rate is slower than the threshold ÷
2808    /// quarantine-risk window. Mailrs measured 14 h between graceful
2809    /// shutdowns with ~30 KB/hr write rate — well below 4 MiB; auto-
2810    /// checkpoint never fired; the entire 14 h of writes was lost on
2811    /// the quarantine-WAL recovery. This time-based companion bounds
2812    /// the data-loss window to roughly `checkpoint_time_threshold`
2813    /// seconds: any execute() arriving more than that interval after
2814    /// the last checkpoint, with at least ONE WAL byte since, fires
2815    /// a checkpoint via the same fire-and-forget worker enqueue.
2816    /// Default 60 s. `SPG_EMBEDDED_CHECKPOINT_SECONDS=0` disables.
2817    checkpoint_time_threshold: Option<core::time::Duration>,
2818    last_checkpoint_at: Mutex<std::time::Instant>,
2819    last_checkpoint_wal_len: Mutex<u64>,
2820    /// v7.1.4 — `<db_path>.spg/segments/` directory. Cold-tier
2821    /// segments produced by `freeze_oldest_to_cold` / compaction
2822    /// are persisted here as `seg_<id>.spg` files; the manifest
2823    /// at `<db_path>.spg/manifest.v10` records every active
2824    /// segment + its CRC32 so the next boot can verify + reload.
2825    cold_segments_dir: PathBuf,
2826    cold_segment_paths: BTreeMap<u32, PathBuf>,
2827    /// v7.17.0 Phase 6.2 — cross-process exclusion lock. Acquired
2828    /// via `fs::create_dir` on `<db_path>.lock` at open_path
2829    /// entry; released on Drop by `fs::remove_dir`. atomic on
2830    /// every supported platform. A second process opening the
2831    /// same path while the first is still alive hits the
2832    /// create_dir failure and returns
2833    /// `EngineError::Unsupported("database is locked by another
2834    /// process: …")`. Stale locks (process crashed mid-session)
2835    /// must be cleared via `Database::force_unlock(path)` —
2836    /// SPG can't safely fingerprint who owned a stale directory
2837    /// without a libc dep, which would violate spg-embedded's
2838    /// zero-deps charter.
2839    lock_path: PathBuf,
2840    /// v7.37.5 (mailrs crash-recovery Ask 1) — in-process registry
2841    /// guard. Drops alongside the rest of the Database, which
2842    /// de-registers `lock_path` from `ACTIVE_OPEN_PATHS`. Carried
2843    /// here so its lifetime exactly matches the live Database
2844    /// handle; a concurrent sibling open_path in the same process
2845    /// refuses honestly while this guard exists.
2846    lock_registry_guard: LockRegistryGuard,
2847    /// CoW-2 (v7.34) — background-checkpoint worker. `None` only
2848    /// transiently inside `Drop` after the worker has been signalled
2849    /// and joined. The worker carries Arc clones of `wal` and
2850    /// `current_chunk_path`, so it can rotate the active chunk and
2851    /// reflect the new path back here even after the front-end has
2852    /// returned to the caller.
2853    checkpoint_worker: Option<CheckpointWorker>,
2854}
2855
2856impl Database {
2857    /// Open a fresh in-memory database. No WAL, no catalog
2858    /// snapshot on disk — perfect for tests + short-lived
2859    /// CLI tools.
2860    #[must_use]
2861    pub fn open_in_memory() -> Self {
2862        Self {
2863            engine: engine_with_query_byte_budget(Engine::new().with_clock(wall_clock_micros)),
2864            persistence: None,
2865            commit_lsn: AtomicU64::new(0),
2866            tx_wal: None,
2867            active_query_count: Arc::new(core::sync::atomic::AtomicU32::new(0)),
2868        }
2869    }
2870
2871    /// v7.1 — Open or create a persistent database backed by
2872    /// the file at `db_path`. The WAL lives at `db_path` +
2873    /// ".wal" (e.g. `./data/spg.db` → `./data/spg.db.wal`). Boot
2874    /// path:
2875    ///
2876    /// 1. If `db_path` exists, restore the catalog snapshot.
2877    /// 2. If the WAL exists, replay every record into the
2878    ///    restored engine — the same recovery story
2879    ///    `spg-server` uses.
2880    /// 3. Open the WAL in append+sync mode so subsequent
2881    ///    `execute()` writes durably commit (one fsync per
2882    ///    mutation).
2883    ///
2884    /// `Drop` writes a final catalog snapshot + truncates the
2885    /// WAL — operators that need a sync barrier at a specific
2886    /// point use `checkpoint()` explicitly.
2887    pub fn open_path(db_path: impl AsRef<Path>) -> Result<Self, EngineError> {
2888        // v7.37.7 A.1 — per-stage timing gated on env var SPG_OPEN_PATH_TIMING.
2889        // v7.37.5 ack reported `open_path 27min→646ms` after the WAL-replay
2890        // fix, but fresh-tarball benchmarks showed ~250 s — strong evidence
2891        // the ack number was on a warm OS page cache. These prints surface
2892        // where the time actually goes per Database::open_path call. Zero
2893        // cost when env unset (one syscall + branch per stage).
2894        let timing = std::env::var_os("SPG_OPEN_PATH_TIMING").is_some();
2895        let timing_start = std::time::Instant::now();
2896        let mut last_stage = timing_start;
2897        let mut stage = |name: &str, last: &mut std::time::Instant| {
2898            if timing {
2899                let now = std::time::Instant::now();
2900                eprintln!(
2901                    "[open_path/{name}] +{:.3}s (total {:.3}s)",
2902                    now.duration_since(*last).as_secs_f64(),
2903                    now.duration_since(timing_start).as_secs_f64()
2904                );
2905                *last = now;
2906            }
2907        };
2908        let db_path = db_path.as_ref().to_path_buf();
2909        stage("entry", &mut last_stage);
2910        // v7.19 — WAL is a directory of chunk files. Legacy
2911        // single-file path stays variable-named `wal_path` for
2912        // the backward-compat migration block below.
2913        let wal_path = {
2914            let mut p = db_path.clone();
2915            let name = p
2916                .file_name()
2917                .map(|n| {
2918                    let mut s = n.to_os_string();
2919                    s.push(".wal");
2920                    s
2921                })
2922                .unwrap_or_else(|| std::ffi::OsString::from(".wal"));
2923            p.set_file_name(name);
2924            p
2925        };
2926        let wal_dir = wal_path.clone();
2927        if let Some(parent) = db_path.parent()
2928            && !parent.as_os_str().is_empty()
2929        {
2930            std::fs::create_dir_all(parent).map_err(io_err)?;
2931        }
2932        // v7.17.0 Phase 6.2 — acquire cross-process exclusion
2933        // lock before touching any catalog / WAL bytes. atomic
2934        // mkdir on every supported platform; a second process
2935        // opening the same path while the first is still alive
2936        // hits the create_dir failure and gets a clear error.
2937        let lock_path = {
2938            let mut p = db_path.clone();
2939            let name = p
2940                .file_name()
2941                .map(|n| {
2942                    let mut s = n.to_os_string();
2943                    s.push(".lock");
2944                    s
2945                })
2946                .unwrap_or_else(|| std::ffi::OsString::from(".lock"));
2947            p.set_file_name(name);
2948            p
2949        };
2950        // v7.37.5 (mailrs crash-recovery Ask 1) — register the
2951        // lock_path in the in-process registry FIRST. Drop on this
2952        // guard de-registers automatically on any early return
2953        // below; storing it in `PersistenceCtx` ties its lifetime
2954        // to the live Database handle. See `LockRegistryGuard`
2955        // docs for why on-disk identity alone wasn't enough.
2956        let lock_registry_guard = LockRegistryGuard::try_acquire(&lock_path)?;
2957        acquire_path_lock(&lock_path)?;
2958        stage("locks", &mut last_stage);
2959        let mut engine = if db_path.exists() {
2960            let bytes = std::fs::read(&db_path).map_err(io_err)?;
2961            stage("fs::read_catalog", &mut last_stage);
2962            let engine = Engine::restore_envelope(&bytes).map_err(|e| {
2963                EngineError::Storage(spg_storage::StorageError::Corrupt(format!(
2964                    "restore from {}: {e}",
2965                    db_path.display()
2966                )))
2967            })?;
2968            stage("restore_envelope", &mut last_stage);
2969            engine_with_query_byte_budget(engine.with_clock(wall_clock_micros))
2970        } else {
2971            engine_with_query_byte_budget(Engine::new().with_clock(wall_clock_micros))
2972        };
2973        // v7.1.4 — manifest-driven cold-segment reload. The
2974        // manifest sidecar pairs the catalog snapshot CRC with a
2975        // list of `(segment_id, path, crc32)` triples; verify
2976        // before loading so a torn or stale manifest doesn't
2977        // surface phantom data.
2978        let cold_segments_dir = {
2979            let parent = db_path.parent().unwrap_or_else(|| Path::new("."));
2980            let stem = db_path
2981                .file_stem()
2982                .unwrap_or_else(|| std::ffi::OsStr::new("db"))
2983                .to_string_lossy()
2984                .into_owned();
2985            parent.join(format!("{stem}.spg")).join("segments")
2986        };
2987        let mut cold_segment_paths: BTreeMap<u32, PathBuf> = BTreeMap::new();
2988        let manifest_pth = spg_manifest_path(&db_path);
2989        if manifest_pth.exists() && db_path.exists() {
2990            let m_bytes = std::fs::read(&manifest_pth).map_err(io_err)?;
2991            if let Ok(m) = CatalogManifest::deserialize(&m_bytes) {
2992                let snap_bytes = std::fs::read(&db_path).map_err(io_err)?;
2993                let snap_crc = spg_crypto::crc32::crc32(&snap_bytes);
2994                if snap_crc == m.catalog_crc32 {
2995                    for entry in &m.cold_segments {
2996                        if let Ok(seg_bytes) = std::fs::read(&entry.path) {
2997                            let computed = spg_crypto::crc32::crc32(&seg_bytes);
2998                            if computed != entry.crc32 {
2999                                eprintln!(
3000                                    "spg-embedded: manifest skip segment {}: CRC mismatch",
3001                                    entry.segment_id
3002                                );
3003                                continue;
3004                            }
3005                            if engine.catalog().cold_segment(entry.segment_id).is_some() {
3006                                // Already loaded via Catalog::clone path (shouldn't happen
3007                                // since Engine::new + restore_envelope don't populate cold).
3008                                continue;
3009                            }
3010                            let mut new_cat = engine.catalog().clone();
3011                            if let Err(e) =
3012                                new_cat.load_segment_bytes_at(entry.segment_id, seg_bytes)
3013                            {
3014                                eprintln!(
3015                                    "spg-embedded: manifest load segment {} failed: {e}",
3016                                    entry.segment_id
3017                                );
3018                                continue;
3019                            }
3020                            engine.replace_catalog(new_cat);
3021                            cold_segment_paths.insert(entry.segment_id, entry.path.clone());
3022                        } else {
3023                            eprintln!(
3024                                "spg-embedded: manifest skip segment {}: file unreadable",
3025                                entry.segment_id
3026                            );
3027                        }
3028                    }
3029                }
3030            }
3031        }
3032        // CoW-4 (v7.34) — D10 + missing-manifest fallback. Walk
3033        // `<db>.spg/segments/` and attach any `seg_<id>.spg` file that
3034        // the manifest didn't already cover (manifest absent / CRC
3035        // mismatched / a fresher freeze landed after the last
3036        // checkpoint wrote its manifest). The segment binary's own
3037        // magic + CRC32 guards integrity — no need to trust a stale
3038        // manifest entry to trust the file.
3039        stage("manifest+cold_segments", &mut last_stage);
3040        scan_cold_segments_dir(&cold_segments_dir, &mut engine, &mut cold_segment_paths);
3041        stage("scan_cold_segments_dir", &mut last_stage);
3042        // v7.19 — chunked WAL on-disk layout.
3043        //
3044        // Three cases handled here:
3045        //
3046        // 1. wal_dir exists as a DIRECTORY → scan its
3047        //    `<unix_us>_<leading_lsn>.wal` chunks (sorted
3048        //    lexicographically = chunk-creation order), replay
3049        //    them in sequence, advance the LSN watermark to the
3050        //    max commit_lsn seen.
3051        //
3052        // 2. wal_path exists as a FILE → legacy v7.18 layout.
3053        //    Migrate it: create `wal_dir/`, move the single file
3054        //    inside as `0000000000000000_0000000000000000.wal`,
3055        //    then fall through to case 1's replay loop.
3056        //
3057        // 3. Neither exists → fresh database; create wal_dir.
3058        let mut initial_lsn: u64 = 0;
3059        if wal_path.is_file() {
3060            // Case 2: legacy single-file WAL migration.
3061            let legacy_bytes = std::fs::read(&wal_path).map_err(io_err)?;
3062            std::fs::remove_file(&wal_path).map_err(io_err)?;
3063            std::fs::create_dir_all(&wal_dir).map_err(io_err)?;
3064            if !legacy_bytes.is_empty() {
3065                let migrated = wal_dir.join(legacy_chunk_filename());
3066                std::fs::write(&migrated, &legacy_bytes).map_err(io_err)?;
3067            }
3068        } else if !wal_dir.exists() {
3069            // Case 3: fresh database.
3070            std::fs::create_dir_all(&wal_dir).map_err(io_err)?;
3071        }
3072        // Cases 1 + 2 share replay logic now that wal_dir is
3073        // guaranteed to exist (and may be empty for case 3).
3074        //
3075        // Two-pass replay so we don't double-apply records the
3076        // snapshot already reflects:
3077        //
3078        // 1. Find the highest commit_lsn carried by a
3079        //    checkpoint_marker across all chunks. That LSN is the
3080        //    snapshot's high-water mark — anything ≤ it is
3081        //    already in `<db_path>` and replaying it would
3082        //    DuplicateTable / double-insert.
3083        // 2. Replay only records strictly above that LSN.
3084        //
3085        // Case 2 migration (legacy single-file WAL) lands here
3086        // too: the migrated chunk has no marker so the LSN floor
3087        // is 0 and every record applies — exactly the v7.18
3088        // behaviour the migration is supposed to preserve.
3089        let chunk_paths = sorted_wal_chunks(&wal_dir).map_err(io_err)?;
3090        stage("wal::sorted_chunks", &mut last_stage);
3091        let mut snapshot_lsn: u64 = 0;
3092        for chunk in &chunk_paths {
3093            let bytes = std::fs::read(chunk).map_err(io_err)?;
3094            if let Ok(records) = parse_wal_records(&bytes) {
3095                for r in &records {
3096                    if r.type_byte == WAL_V4_TYPE_CHECKPOINT_MARKER {
3097                        if let Some(l) = r.commit_lsn {
3098                            if l > snapshot_lsn {
3099                                snapshot_lsn = l;
3100                            }
3101                        }
3102                    }
3103                }
3104            }
3105        }
3106        stage("wal::snapshot_lsn_scan", &mut last_stage);
3107        let mut quarantined: Vec<QuarantinedStmt> = Vec::new();
3108        let mut total_replayed = 0usize;
3109        for chunk in &chunk_paths {
3110            let bytes = std::fs::read(chunk).map_err(io_err)?;
3111            if bytes.is_empty() {
3112                continue;
3113            }
3114            let applied = replay_wal_filtered(&bytes, &mut engine, snapshot_lsn, &mut quarantined)
3115                .map_err(|m| EngineError::Storage(spg_storage::StorageError::Corrupt(m)))?;
3116            total_replayed = total_replayed.saturating_add(applied);
3117            if let Ok(records) = parse_wal_records(&bytes) {
3118                if let Some(max) = records.iter().filter_map(|r| r.commit_lsn).max() {
3119                    if max > initial_lsn {
3120                        initial_lsn = max;
3121                    }
3122                }
3123            }
3124        }
3125        stage("wal::replay_filtered", &mut last_stage);
3126        // v7.30.1 (mailrs round-24 ask 2) — replay rejects no longer
3127        // brick the open. Persist the rejected statements beside the
3128        // WAL chunks for forensics and say so loudly; the boot
3129        // continues with every other record applied.
3130        if !quarantined.is_empty() {
3131            let mut body = String::new();
3132            for q in &quarantined {
3133                body.push_str(&format_quarantine_line(q));
3134            }
3135            let qpath = wal_dir.join(format!(
3136                "quarantine-{:016x}.log",
3137                wall_clock_micros().max(0) as u64
3138            ));
3139            match std::fs::write(&qpath, &body) {
3140                Ok(()) => eprintln!(
3141                    "spg-embedded: WAL replay quarantined {} statement(s) — boot continues; \
3142                     forensics at {}",
3143                    quarantined.len(),
3144                    qpath.display()
3145                ),
3146                Err(e) => eprintln!(
3147                    "spg-embedded: WAL replay quarantined {} statement(s) — boot continues; \
3148                     quarantine file write FAILED ({e}), entries follow:\n{body}",
3149                    quarantined.len()
3150                ),
3151            }
3152        }
3153        // Open the "current" chunk — either the last existing
3154        // chunk file (so subsequent appends extend it until the
3155        // size threshold rotates) or a fresh first chunk.
3156        let now_us = wall_clock_micros();
3157        let current_chunk_path = if let Some(last) = chunk_paths.last() {
3158            last.clone()
3159        } else {
3160            wal_dir.join(chunk_filename(now_us, initial_lsn + 1))
3161        };
3162        let wal_file = OpenOptions::new()
3163            .create(true)
3164            .append(true)
3165            .read(true)
3166            .open(&current_chunk_path)
3167            .map_err(io_err)?;
3168        // Persist the (possibly freshly created) chunk's directory entry.
3169        fsync_dir(&wal_dir);
3170        let wal_len = wal_file.metadata().map_err(io_err)?.len();
3171        let wal = Arc::new(WalGroup::new(wal_file, wal_len));
3172        // v7.19 P3 — spawn retention sweep thread when the
3173        // operator opted in via SPG_PITR_RETENTION_HOURS > 0.
3174        // Otherwise stay on the v7.18 behaviour (chunks accumulate
3175        // until something else — backup-pitr archival, manual
3176        // cleanup — moves them).
3177        let retention_hours = pitr_retention_hours();
3178        let (retention_shutdown, retention_thread) = if retention_hours > 0 {
3179            let shutdown = Arc::new(AtomicBool::new(false));
3180            let shutdown_clone = Arc::clone(&shutdown);
3181            let wal_dir_clone = wal_dir.clone();
3182            let check_interval = std::time::Duration::from_secs(pitr_retention_check_sec());
3183            let archive_cmd = pitr_archive_cmd();
3184            let handle = std::thread::Builder::new()
3185                .name("spg-pitr-retention".into())
3186                .spawn(move || {
3187                    retention_sweep_loop(
3188                        wal_dir_clone,
3189                        retention_hours,
3190                        check_interval,
3191                        archive_cmd,
3192                        shutdown_clone,
3193                    );
3194                })
3195                .map_err(io_err)?;
3196            (Some(shutdown), Some(handle))
3197        } else {
3198            (None, None)
3199        };
3200        // v7.20 — background flusher for SPG_SYNCHRONOUS_COMMIT=off.
3201        let (flusher_shutdown, flusher_thread) = if synchronous_commit_on() {
3202            (None, None)
3203        } else {
3204            let shutdown = Arc::new(AtomicBool::new(false));
3205            let shutdown_clone = Arc::clone(&shutdown);
3206            let group = Arc::clone(&wal);
3207            let interval = std::time::Duration::from_millis(wal_writer_delay_ms());
3208            let handle = std::thread::Builder::new()
3209                .name("spg-wal-flusher".into())
3210                .spawn(move || {
3211                    while !shutdown_clone.load(Ordering::SeqCst) {
3212                        std::thread::sleep(interval);
3213                        if let Err(e) = group.flush_now() {
3214                            eprintln!("spg-embedded: background WAL flush failed: {e:?}");
3215                        }
3216                    }
3217                    // Final drain on shutdown signal.
3218                    let _ = group.flush_now();
3219                })
3220                .map_err(io_err)?;
3221            (Some(shutdown), Some(handle))
3222        };
3223        // v7.34 (crash-recovery P0 #2) — arm row-level redo capture for
3224        // subsequent writes (AFTER replay, so re-executed SQL records
3225        // don't capture; 0x13 records replay via apply_redo and never do).
3226        if row_redo_enabled() {
3227            engine.set_redo_capture(true);
3228        }
3229        let mut db = Self {
3230            engine,
3231            commit_lsn: AtomicU64::new(initial_lsn),
3232            tx_wal: None,
3233            active_query_count: Arc::new(core::sync::atomic::AtomicU32::new(0)),
3234            persistence: Some(PersistenceCtx {
3235                db_path,
3236                wal_dir,
3237                current_chunk_path: Arc::new(Mutex::new(current_chunk_path)),
3238                wal,
3239                checkpoint_threshold_bytes: default_checkpoint_threshold_bytes(),
3240                // v7.37.13 (A1.8) — adaptive when no env pin.
3241                adaptive_threshold_enabled: std::env::var_os("SPG_EMBEDDED_CHECKPOINT_BYTES")
3242                    .is_none(),
3243                ewma_wal_rate_bytes_per_sec: Mutex::new(0),
3244                // v7.37.13 (A1.9) — fresh stats sink, shared with worker.
3245                checkpoint_stats: Arc::new(Mutex::new(CheckpointStats::default())),
3246                checkpoint_time_threshold: default_checkpoint_time_threshold(),
3247                last_checkpoint_at: Mutex::new(std::time::Instant::now()),
3248                last_checkpoint_wal_len: Mutex::new(0),
3249                cold_segments_dir,
3250                cold_segment_paths,
3251                lock_path,
3252                lock_registry_guard,
3253                retention_shutdown,
3254                retention_thread,
3255                flusher_shutdown,
3256                flusher_thread,
3257                checkpoint_worker: Some(CheckpointWorker::spawn()),
3258            }),
3259        };
3260        // v7.37.2 (mailrs prod 7.35 pool-exhaustion incident — surface
3261        // fix per `feedback-zero-customer-change-warmup-incident`) —
3262        // automatic cold-tier OS page-cache warm-up so the catalog is
3263        // fully server-ready on return. The client never sees a SPG-
3264        // specific call site; `open_path` behaves like PG's "ready to
3265        // accept queries" semantics. Bounded by
3266        // `SPG_WARM_UP_COLD_BUDGET_MS` (default unset = no cap;
3267        // env-only spec channel, never a client-visible API). `0` =
3268        // skip warm-up entirely (escape hatch for fast restart).
3269        stage("pre_autowarm", &mut last_stage);
3270        autowarm_cold_tier_on_open(&db);
3271        stage("autowarm", &mut last_stage);
3272        // v7.37.8 + v7.38 followup (mailrs lock-hang 4th-recurrence
3273        // root-cause closure §"Open asks", ack §1) — if this boot
3274        // actually replayed any records, force a checkpoint right
3275        // here so the floor advances past them. Next restart skips
3276        // them entirely via `snapshot_lsn_scan` + the marker the
3277        // checkpoint emits. This is the in-place equivalent of an
3278        // explicit V4 → V5 migration without rewriting WAL records:
3279        // the post-replay catalog is snapshotted as the new
3280        // authoritative image, and stale V4 records become
3281        // skip-able on the next boot via the floor mechanism.
3282        //
3283        // Cost: ONE additional ~1-3 s catalog write on the upgrade
3284        // boot (the boot already paid the ~187 s replay tax — this
3285        // is +1-3% on top). Benefit: every subsequent restart
3286        // permanently fast (V4 records below the new floor are
3287        // skipped, V5 records replay in O(rows changed)).
3288        //
3289        // Failure path: checkpoint errors are logged to stderr but
3290        // never propagate — the catalog state is in memory and
3291        // valid; the next boot will replay again, which is exactly
3292        // the pre-fix behaviour. So the only regression is "this
3293        // optimisation didn't take effect this boot", not "the boot
3294        // failed".
3295        if total_replayed > 0 {
3296            stage("pre_replay_checkpoint", &mut last_stage);
3297            if let Err(e) = db.checkpoint() {
3298                eprintln!(
3299                    "spg-embedded: post-replay checkpoint failed: {e:?} \
3300                     (WAL is intact; next boot will replay {total_replayed} \
3301                     records again — non-fatal)"
3302                );
3303            }
3304            stage("post_replay_checkpoint", &mut last_stage);
3305        }
3306        Ok(db)
3307    }
3308
3309    /// v7.1.4 — freeze the oldest `max_rows` of `table_name`'s
3310    /// hot tier into a brand-new cold-tier segment + persist
3311    /// it to disk. Same semantics as `spg-server`'s freezer
3312    /// thread; embedded just runs the freeze synchronously on
3313    /// the caller's thread. Persistence + manifest update
3314    /// happen as part of the next `checkpoint()` (or on Drop).
3315    pub fn freeze_oldest_to_cold(
3316        &mut self,
3317        table_name: &str,
3318        index_name: &str,
3319        max_rows: usize,
3320    ) -> Result<spg_storage::FreezeReport, EngineError> {
3321        let report = self
3322            .engine
3323            .freeze_oldest_to_cold(table_name, index_name, max_rows)?;
3324        if let Some(p) = &mut self.persistence {
3325            std::fs::create_dir_all(&p.cold_segments_dir).map_err(io_err)?;
3326            let final_path = p
3327                .cold_segments_dir
3328                .join(format!("seg_{}.spg", report.segment_id));
3329            let tmp_path = p
3330                .cold_segments_dir
3331                .join(format!("seg_{}.spg.tmp", report.segment_id));
3332            // v7.38 (read01 P5.09) — fsync the segment bytes before the
3333            // rename so a full durable_rename (file data + dir entry) is in
3334            // effect. Previously std::fs::write left the content unflushed,
3335            // so a crash after rename could expose a named-but-empty segment
3336            // the catalog already references.
3337            {
3338                use std::io::Write;
3339                let mut f = std::fs::File::create(&tmp_path).map_err(io_err)?;
3340                f.write_all(&report.segment_bytes).map_err(io_err)?;
3341                f.sync_all().map_err(io_err)?;
3342            }
3343            std::fs::rename(&tmp_path, &final_path).map_err(io_err)?;
3344            // v7.37.13 (A1.6) — fsync the parent directory so the
3345            // rename's directory entry is durable. `std::fs::rename`
3346            // makes the new name visible to this process but does not
3347            // by itself flush the directory inode; a power loss
3348            // between rename() and the next checkpoint's catalog
3349            // fsync would lose the seg_<id>.spg entry and leave the
3350            // catalog pointing at a path the kernel claims does not
3351            // exist. Matches PG's `durable_rename` posture.
3352            fsync_dir(&p.cold_segments_dir);
3353            // v7.37.13 (A1.7) — hint the kernel that the cold-segment
3354            // bytes won't be needed again soon (POSIX_FADV_DONTNEED).
3355            // Without this, a long-running process freezing a steady
3356            // trickle of segments accumulates a stale page-cache
3357            // footprint proportional to the cold tier — competing
3358            // with hot-tier reads for memory.
3359            fadvise_dontneed_file(&final_path);
3360            p.cold_segment_paths.insert(report.segment_id, final_path);
3361        }
3362        Ok(report)
3363    }
3364
3365    /// v7.1 — override the auto-checkpoint WAL-size ceiling for
3366    /// this `Database` instance. Default is
3367    /// `SPG_EMBEDDED_CHECKPOINT_BYTES` env (4 MiB if unset); the
3368    /// setter wins. No-op when the database is in-memory.
3369    pub fn set_checkpoint_threshold_bytes(&mut self, bytes: u64) {
3370        if let Some(p) = &mut self.persistence {
3371            p.checkpoint_threshold_bytes = bytes.max(1);
3372        }
3373    }
3374
3375    /// v7.37.13 — test-friendly setter for the time-based
3376    /// checkpoint threshold (env-var equivalent
3377    /// `SPG_EMBEDDED_CHECKPOINT_SECONDS`). `None` disables the
3378    /// timer; `Some(d)` sets the interval. No-op when the database
3379    /// is in-memory.
3380    ///
3381    /// Resets the bookkeeping (`last_checkpoint_at` /
3382    /// `last_checkpoint_wal_len`) so the next write after this call
3383    /// can fire the time trigger immediately (no need to wait the
3384    /// full new interval again).
3385    pub fn set_checkpoint_time_threshold(&mut self, threshold: Option<core::time::Duration>) {
3386        if let Some(p) = &mut self.persistence {
3387            p.checkpoint_time_threshold = threshold;
3388            // Reset bookkeeping so the next write evaluates against
3389            // a fresh window (avoids "set to 1 s then wait the old
3390            // 60 s anyway because last_checkpoint_at hasn't moved").
3391            *p.last_checkpoint_at
3392                .lock()
3393                .unwrap_or_else(|e| e.into_inner()) = std::time::Instant::now()
3394                .checked_sub(threshold.unwrap_or_default())
3395                .unwrap_or_else(std::time::Instant::now);
3396            *p.last_checkpoint_wal_len
3397                .lock()
3398                .unwrap_or_else(|e| e.into_inner()) = 0;
3399        }
3400    }
3401
3402    /// v7.31 (memory campaign, round-26 ask 1/ask 4) — per-bucket
3403    /// memory snapshot for the embedding host. Poll it from prod to
3404    /// see where resident bytes live (rows / representation /
3405    /// indexes per table) and to drive host-side shedding before
3406    /// the kernel does it. Same numbers as the server path's
3407    /// `SELECT * FROM spg_memory_stats`.
3408    #[must_use]
3409    pub fn memory_stats(&self) -> spg_engine::MemoryStats {
3410        let mut stats = self.engine.memory_stats();
3411        // v7.31 C2 — fill in bucket D: the engine leaves `wal_bytes`
3412        // None (it has no WAL); we report the live (uncheckpointed)
3413        // WAL footprint via the same `written_len()` meter `metrics()`
3414        // reads. In-memory databases have no persistence → stays None.
3415        if let Some(p) = &self.persistence {
3416            stats.wal_bytes = Some(p.wal.written_len());
3417        }
3418        stats
3419    }
3420
3421    /// v7.1 — flush a fresh catalog snapshot to `db_path` and
3422    /// rotate the WAL. Idempotent; cheap when nothing has happened
3423    /// since the last checkpoint. No-op when the database is in-memory.
3424    ///
3425    /// CoW-2 (v7.34): the heavy half (serialize + tmp+rename + fsync +
3426    /// marker enqueue + chunk rotation) runs on a dedicated worker thread
3427    /// so the caller's engine borrow is released after the cheap capture
3428    /// step. This entry point keeps the **synchronous** contract — it
3429    /// waits for the worker to finish before returning — so existing
3430    /// callers, tests, and operator scripts see no behaviour change;
3431    /// they just pay one extra hop. The non-blocking variant lives at
3432    /// `trigger_checkpoint`, used by the auto-checkpoint hot path so
3433    /// the write that crossed `SPG_EMBEDDED_CHECKPOINT_BYTES` doesn't
3434    /// stall on disk IO.
3435    ///
3436    /// Called automatically when:
3437    /// - the WAL grows past `SPG_EMBEDDED_CHECKPOINT_BYTES` (default
3438    ///   4 MiB) at the end of an `execute()` (via `trigger_checkpoint`,
3439    ///   non-blocking), and
3440    /// - `Drop` runs (synchronous; best-effort, failures logged).
3441    pub fn checkpoint(&mut self) -> Result<(), EngineError> {
3442        if self.persistence.is_none() {
3443            return Ok(());
3444        }
3445        // Drain any prior async checkpoint first so our snapshot reflects
3446        // post-it state (and so a sticky error from it surfaces here, not
3447        // smeared across the next two `wait`s).
3448        self.wait_checkpoint()?;
3449        let Some(job) = self.snapshot_checkpoint_job() else {
3450            return Ok(());
3451        };
3452        let Some(worker) = self
3453            .persistence
3454            .as_ref()
3455            .and_then(|p| p.checkpoint_worker.as_ref())
3456        else {
3457            return Ok(());
3458        };
3459        // `wait_checkpoint` above guaranteed idle; `try_enqueue` only
3460        // returns Ok(false) when busy, so we expect Ok(true) here. The
3461        // bool is dropped — we wait unconditionally to honour the sync
3462        // contract.
3463        let _ = worker.try_enqueue(job)?;
3464        self.wait_checkpoint()
3465    }
3466
3467    /// CoW-2 (v7.34) — non-blocking checkpoint trigger used by the
3468    /// auto-checkpoint hot path (`wal_after_ok` over the threshold).
3469    /// Captures the engine state under `&mut self` then signals the
3470    /// background worker and returns; the serialize / fsync / rotate
3471    /// sequence runs on the worker thread. If a checkpoint is already
3472    /// pending or in flight, the new trigger is silently dropped —
3473    /// the next threshold crossing picks up the newer state.
3474    ///
3475    /// Sticky errors from a prior async run surface here (via
3476    /// `try_enqueue`), so a failed background checkpoint still reaches
3477    /// the caller eventually rather than vanishing.
3478    /// v7.38 (read01 P5.02) — returns `true` only when the job was actually
3479    /// enqueued. The worker drops a job (returns `false`) when a checkpoint
3480    /// is already pending / inflight; the caller must NOT then advance its
3481    /// `last_checkpoint_at` bookkeeping, or it would record a checkpoint that
3482    /// never ran and delay the retry, widening the data-loss window.
3483    fn trigger_checkpoint(&self) -> Result<bool, EngineError> {
3484        if self.persistence.is_none() {
3485            return Ok(false);
3486        }
3487        let Some(job) = self.snapshot_checkpoint_job() else {
3488            return Ok(false);
3489        };
3490        let Some(worker) = self
3491            .persistence
3492            .as_ref()
3493            .and_then(|p| p.checkpoint_worker.as_ref())
3494        else {
3495            return Ok(false);
3496        };
3497        worker.try_enqueue(job)
3498    }
3499
3500    /// v7.37.13 (A1.1) — public façade over the sync
3501    /// [`Self::trigger_checkpoint`] so async wrappers
3502    /// (`spg-embedded-tokio::AsyncDatabase`) can drive a self-wake
3503    /// timer without owning `&mut Database`. The underlying
3504    /// implementation is `&self`-pure (snapshot_checkpoint_job +
3505    /// worker.try_enqueue both go through Arc-shared state); this
3506    /// wrapper just gives an externally-callable name.
3507    ///
3508    /// Returns `Ok(())` on a successful enqueue OR a deduplicated
3509    /// skip (worker already busy); surfaces sticky errors from the
3510    /// last worker run so a failed background checkpoint reaches
3511    /// the caller.
3512    pub fn maybe_trigger_checkpoint(&self) -> Result<(), EngineError> {
3513        self.trigger_checkpoint().map(|_accepted| ())
3514    }
3515
3516    /// v7.37.13 (A1.1) — current checkpoint time threshold, or
3517    /// `None` if disabled. Self-wake timers read this to schedule
3518    /// their ticks at the right cadence.
3519    #[must_use]
3520    pub fn checkpoint_time_threshold(&self) -> Option<core::time::Duration> {
3521        self.persistence
3522            .as_ref()
3523            .and_then(|p| p.checkpoint_time_threshold)
3524    }
3525
3526    /// v7.37.13 (A1.8 [PG+]) — current (possibly adaptive) byte
3527    /// threshold for the auto-checkpoint trigger. Tests and
3528    /// diagnostics read this to observe whether the EWMA-driven
3529    /// recompute kicked in. Production callers normally ignore it.
3530    #[must_use]
3531    pub fn checkpoint_threshold_bytes(&self) -> u64 {
3532        self.persistence
3533            .as_ref()
3534            .map_or(0, |p| p.checkpoint_threshold_bytes)
3535    }
3536
3537    /// v7.37.13 (A1.8 [PG+]) — current EWMA estimate of WAL
3538    /// growth rate (bytes/sec). 0 = "no data yet" (first checkpoint
3539    /// hasn't fired) or non-adaptive build. Reading is cheap (one
3540    /// mutex acquire); intended for /spg_stat_* style introspection
3541    /// and the v7_37_13_adaptive_threshold_* TDD tests.
3542    #[must_use]
3543    pub fn ewma_wal_rate_bytes_per_sec(&self) -> u64 {
3544        self.persistence.as_ref().map_or(0, |p| {
3545            *p.ewma_wal_rate_bytes_per_sec
3546                .lock()
3547                .unwrap_or_else(|e| e.into_inner())
3548        })
3549    }
3550
3551    /// v7.37.13 (A1.4 / A1.5 TDD) — arm the next WAL sync_data on
3552    /// this Database's WalGroup to return EIO. One-shot: consumed
3553    /// by the next sync. Per-instance so parallel tests don't
3554    /// stomp each other. Released builds compile this away.
3555    #[cfg(test)]
3556    pub(crate) fn arm_wal_fsync_fail_for_testing(&self) {
3557        if let Some(p) = self.persistence.as_ref() {
3558            p.wal.arm_fsync_fail();
3559        }
3560    }
3561
3562    /// v7.37.13 (A1.9) — snapshot of the current checkpoint stats.
3563    /// Returned by value (clone) so callers don't hold the mutex
3564    /// across their own work. PG-equivalent of `LogCheckpointEnd`'s
3565    /// data; SPG additionally exposes a rolling p50/p95/p99 over
3566    /// the last [`CHECKPOINT_STATS_WINDOW`] checkpoints via
3567    /// [`CheckpointStats::percentiles`].
3568    #[must_use]
3569    pub fn checkpoint_stats(&self) -> CheckpointStats {
3570        self.persistence
3571            .as_ref()
3572            .map_or_else(CheckpointStats::default, |p| {
3573                p.checkpoint_stats
3574                    .lock()
3575                    .unwrap_or_else(|e| e.into_inner())
3576                    .clone()
3577            })
3578    }
3579
3580    /// CoW-2 (v7.34) — block until the background checkpoint worker is
3581    /// idle. Used by sync `checkpoint()` and by Drop to ensure the final
3582    /// snapshot is durable before the process exits.
3583    fn wait_checkpoint(&self) -> Result<(), EngineError> {
3584        match self
3585            .persistence
3586            .as_ref()
3587            .and_then(|p| p.checkpoint_worker.as_ref())
3588        {
3589            Some(w) => w.wait(),
3590            None => Ok(()),
3591        }
3592    }
3593
3594    /// v7.37.13 — public façade over [`Self::wait_checkpoint`] for
3595    /// test code that needs to drain the async checkpoint worker.
3596    /// Production callers use the synchronous [`Self::checkpoint`]
3597    /// which already drains internally.
3598    pub fn checkpoint_wait(&self) -> Result<(), EngineError> {
3599        self.wait_checkpoint()
3600    }
3601
3602    /// CoW-2 (v7.34) — capture a checkpoint job under `&mut self` (or
3603    /// `&self`, since reading from atomics + cheap clones don't mutate).
3604    /// Returns `None` if the database is in-memory.
3605    fn snapshot_checkpoint_job(&self) -> Option<CheckpointJob> {
3606        let p = self.persistence.as_ref()?;
3607        Some(CheckpointJob {
3608            snapshot: self.engine.snapshot_data(),
3609            marker_lsn: self.commit_lsn.load(Ordering::SeqCst),
3610            db_path: p.db_path.clone(),
3611            wal_dir: p.wal_dir.clone(),
3612            wal: Arc::clone(&p.wal),
3613            cold_segments: p
3614                .cold_segment_paths
3615                .iter()
3616                .map(|(&id, path)| (id, path.clone()))
3617                .collect(),
3618            current_chunk_path: Arc::clone(&p.current_chunk_path),
3619            stats: Arc::clone(&p.checkpoint_stats),
3620        })
3621    }
3622
3623    /// Restore a database from a previously-captured catalog
3624    /// snapshot. Pairs with `Database::snapshot()` for
3625    /// round-tripping in-memory state without going through
3626    /// the `spg-server` WAL.
3627    pub fn restore(snapshot: &[u8]) -> Result<Self, EngineError> {
3628        let engine = Engine::restore_envelope(snapshot).map_err(|e| {
3629            EngineError::Storage(spg_storage::StorageError::Corrupt(format!("restore: {e}")))
3630        })?;
3631        let db = Self {
3632            engine,
3633            persistence: None,
3634            commit_lsn: AtomicU64::new(0),
3635            tx_wal: None,
3636            active_query_count: Arc::new(core::sync::atomic::AtomicU32::new(0)),
3637        };
3638        // v7.37.2 — auto-warm on snapshot restore for the same reason
3639        // `open_path` does (catalog is server-ready when constructor
3640        // returns; client never sees a SPG-specific warmup call).
3641        autowarm_cold_tier_on_open(&db);
3642        Ok(db)
3643    }
3644
3645    /// Take a catalog snapshot suitable for `Database::restore`.
3646    /// The bytes are SPG's canonical catalog envelope (FILE_MAGIC
3647    /// + version + payload); round-trips through every released
3648    /// SPG version per the STABILITY contract.
3649    #[must_use]
3650    pub fn snapshot(&self) -> Vec<u8> {
3651        self.engine.snapshot()
3652    }
3653
3654    /// v7.36 (mailrs ask #4) — programmatic `EXPLAIN` over `sql`,
3655    /// returning each line of the QUERY PLAN as an owned `String`.
3656    /// Skips the WAL (`EXPLAIN` is read-only) and runs against the
3657    /// engine's live catalog. Dogfood callers can attach the plan
3658    /// to a report or assert on its shape from a test without
3659    /// having to parse a tabular result themselves.
3660    ///
3661    /// `sql` is the inner SELECT (no `EXPLAIN` prefix); the helper
3662    /// adds it. For SQL with `$N` placeholders, substitute them
3663    /// into the SQL string before calling — programmatic
3664    /// placeholder-aware EXPLAIN is on the v7.37 plan.
3665    ///
3666    /// # Errors
3667    /// Propagates parse errors on `sql`, plus any engine error the
3668    /// `EXPLAIN` itself raises (table not found, column not found).
3669    pub fn explain(&self, sql: &str) -> Result<Vec<String>, EngineError> {
3670        let full = format!("EXPLAIN {sql}");
3671        let result = self.engine.execute_readonly(&full)?;
3672        Ok(extract_query_plan_lines(result))
3673    }
3674
3675    /// Write-side single-statement execute. Runs the SQL through
3676    /// the buffered group-commit pipeline and blocks until the
3677    /// resulting batch's WAL fsync returns. Read-only statements
3678    /// (SELECT / SHOW / EXPLAIN / BEGIN-COMMIT-ROLLBACK /
3679    /// CHECKPOINT / COMPACT etc.) skip the WAL entirely.
3680    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
3681        // v7.20 P2 — single-caller convenience over the buffered
3682        // path: enqueue + immediately wait. Batch size is 1 here,
3683        // so the durability behaviour (one fsync before Ok) is
3684        // identical to v7.19. Concurrent callers go through
3685        // `execute_buffered` (AsyncDatabase does) and share the
3686        // leader's fsync.
3687        //
3688        // v7.37.14 (A2.2) — bump active_query_count for the
3689        // duration so the background freezer / flusher can back
3690        // off when foreground queries are in flight (RAII guard
3691        // decrements on every exit path, including panic-unwind).
3692        // Clone the Arc into a local so the guard isn't a borrow
3693        // of `self.active_query_count` (would block the `&mut
3694        // self.execute_buffered` call below).
3695        let counter_arc = Arc::clone(&self.active_query_count);
3696        let _busy_guard = ActiveQueryGuard::new(&counter_arc);
3697        // v7.39 (round 249) — `COPY … FROM '<file>'`: the no_std engine
3698        // performs no I/O, so the HOST reads the file here and lowers to
3699        // per-row INSERTs through the normal execute path (each row gets
3700        // its WAL record; the wrapping transaction makes the whole COPY
3701        // one atomic, one-fsync commit — and PG's all-or-nothing COPY).
3702        // v7.39 (round 252) — `COPY … TO '<file>'`: the engine renders the
3703        // payload (read-only), the HOST writes the file.
3704        if sql_head_is_copy(sql)
3705            && let Some(spec) = spg_engine::copy::parse_copy_to_file(sql)
3706        {
3707            let (payload, n) = self.engine.copy_to_buffer(
3708                &spec.table,
3709                spec.columns.as_deref(),
3710                spec.query.as_deref(),
3711                &spec.options,
3712            )?;
3713            std::fs::write(&spec.path, payload).map_err(|e| {
3714                let os = e.to_string();
3715                let os = os.split(" (os error").next().unwrap_or(&os).to_string();
3716                EngineError::Unsupported(format!(
3717                    "could not open file \"{path}\" for writing: {os}",
3718                    path = spec.path
3719                ))
3720            })?;
3721            return Ok(QueryResult::CommandOk {
3722                affected: n,
3723                modified_catalog: false,
3724            });
3725        }
3726        // v7.39 (round 343, V40) — the two lo_* calls that touch a file.
3727        // Same host contract COPY-from-a-file uses: the engine owns the
3728        // shape and the messages, the host owns the `std::fs`. An embed
3729        // host runs as its own process, so there is no role to check —
3730        // the caller already has the filesystem.
3731        if let Some(call) = spg_engine::largeobject::parse_lo_file_call(sql) {
3732            use spg_engine::largeobject::LoFileCall;
3733            let value = match &call {
3734                LoFileCall::Import { path, oid } => {
3735                    let data = std::fs::read(path).map_err(|e| {
3736                        EngineError::Unsupported(spg_engine::largeobject::could_not_open(
3737                            path,
3738                            &e.to_string(),
3739                        ))
3740                    })?;
3741                    i64::from(self.engine.lo_import_bytes(oid.unwrap_or(0), data)?)
3742                }
3743                LoFileCall::Export { oid, path } => {
3744                    let bytes = self.engine.lo_export_bytes(*oid)?;
3745                    std::fs::write(path, &bytes).map_err(|e| {
3746                        EngineError::Unsupported(spg_engine::largeobject::could_not_create(
3747                            path,
3748                            &e.to_string(),
3749                        ))
3750                    })?;
3751                    1
3752                }
3753            };
3754            return Ok(QueryResult::Rows {
3755                columns: vec![spg_storage::ColumnSchema::new(
3756                    call.column_name().to_string(),
3757                    spg_storage::DataType::BigInt,
3758                    false,
3759                )],
3760                rows: vec![spg_storage::Row::new(vec![spg_storage::Value::BigInt(
3761                    value,
3762                )])],
3763            });
3764        }
3765        if sql_head_is_copy(sql)
3766            && let Some(spec) = spg_engine::copy::parse_copy_from_file(sql)
3767        {
3768            let target = self
3769                .engine
3770                .copy_target_columns(&spec.table, spec.columns.as_deref())?;
3771            let data = std::fs::read_to_string(&spec.path).map_err(|e| {
3772                // PG's wording, without std's " (os error N)" suffix.
3773                let os = e.to_string();
3774                let os = os.split(" (os error").next().unwrap_or(&os).to_string();
3775                EngineError::Unsupported(format!(
3776                    "could not open file \"{path}\" for reading: {os}",
3777                    path = spec.path
3778                ))
3779            })?;
3780            let inserts = spg_engine::copy::copy_buffer_inserts(
3781                &spec.table,
3782                spec.columns.as_deref(),
3783                &target,
3784                &spec.options,
3785                &data,
3786            )?;
3787            let wrap = !self.engine.in_transaction();
3788            if wrap {
3789                self.execute("BEGIN")?;
3790            }
3791            let mut affected: usize = 0;
3792            for insert in &inserts {
3793                match self.execute(insert) {
3794                    Ok(QueryResult::CommandOk { affected: n, .. }) => affected += n,
3795                    Ok(_) => affected += 1,
3796                    Err(e) => {
3797                        if wrap {
3798                            let _ = self.execute("ROLLBACK");
3799                        }
3800                        return Err(e);
3801                    }
3802                }
3803            }
3804            if wrap {
3805                self.execute("COMMIT")?;
3806            }
3807            return Ok(QueryResult::CommandOk {
3808                affected,
3809                modified_catalog: false,
3810            });
3811        }
3812        let (result, ticket) = self.execute_buffered(sql)?;
3813        if let Some(t) = ticket {
3814            // v7.39 (round 171) — session `synchronous_commit = off`
3815            // skips the durability wait (PG semantics); the ticket's
3816            // record is already enqueued and flushes asynchronously.
3817            if self.session_synchronous_commit() {
3818                t.wait()?;
3819            }
3820        }
3821        Ok(result)
3822    }
3823
3824    /// v7.39 (round 171) — session-level `synchronous_commit` (the PG
3825    /// GUC): `SET synchronous_commit = off` makes execute() return
3826    /// after the WAL enqueue without waiting for the fsync — the
3827    /// background flusher / next synchronous commit / clean shutdown
3828    /// makes it durable (exactly PG's documented trade). `local` /
3829    /// `remote_*` levels all wait locally, like PG on a standalone
3830    /// primary. Falls back to the process-level
3831    /// SPG_SYNCHRONOUS_COMMIT env default when the session never set
3832    /// the GUC.
3833    fn session_synchronous_commit(&self) -> bool {
3834        match self.engine.session_param("synchronous_commit") {
3835            Some(v) => {
3836                !(v.eq_ignore_ascii_case("off") || v == "0" || v.eq_ignore_ascii_case("false"))
3837            }
3838            None => synchronous_commit_on(),
3839        }
3840    }
3841
3842    /// v7.37.14 (A2.2) — clone the shared active-query counter
3843    /// so background tasks (freezer / flusher / future schedulers)
3844    /// can read foreground load without locking the engine.
3845    /// Returns `0` for in-memory dbs that have no spawned
3846    /// background work.
3847    #[must_use]
3848    pub fn active_query_count_handle(&self) -> Arc<core::sync::atomic::AtomicU32> {
3849        Arc::clone(&self.active_query_count)
3850    }
3851
3852    /// v7.37.9 — apply a decoded V5 row-redo log directly to the
3853    /// engine. Used by spgctl's PITR restore path to handle
3854    /// `WAL_V5_TYPE_ROW_REDO` (0x13) records the same way `open_path`
3855    /// does in its replay loop. Without this, spgctl errors out on
3856    /// any WAL chunk whose floor was past v7.37.8's SPG_WAL_ROW_REDO
3857    /// default-ON flip — exactly the failing-test shape in
3858    /// `crates/spgctl/src/main.rs::tests::pitr_restore_*`.
3859    pub fn apply_redo(&mut self, changes: &[spg_storage::RowChange]) -> Result<(), EngineError> {
3860        self.engine.apply_redo(changes)
3861    }
3862
3863    /// v7.20 P2 — group-commit write entry. Runs the engine
3864    /// mutation + encodes/enqueues the WAL record, then RETURNS
3865    /// WITHOUT waiting for the fsync. The caller must call
3866    /// [`WalTicket::wait`] before treating the write as durable
3867    /// — crucially, the caller can (and should) drop whatever
3868    /// lock guards this `Database` first, so the next writer's
3869    /// mutation overlaps this batch's fsync.
3870    ///
3871    /// `None` ticket = nothing hit the WAL (read-only statement,
3872    /// no-op DDL, or in-memory database) — the result is final
3873    /// as returned.
3874    ///
3875    /// # Errors
3876    /// Engine errors propagate unchanged. Auto-checkpoint (when
3877    /// the active chunk crosses the threshold) runs inline and
3878    /// may surface IO errors.
3879    pub fn execute_buffered(
3880        &mut self,
3881        sql: &str,
3882    ) -> Result<(QueryResult, Option<WalTicket>), EngineError> {
3883        let result = self.engine.execute(sql)?;
3884        // v7.37 Epic Du — a bare `CHECKPOINT` forces an immediate,
3885        // synchronous checkpoint, matching PG where CHECKPOINT flushes
3886        // a durability barrier now instead of waiting for the auto
3887        // (byte / time) trigger. The engine parsed it as a no-op
3888        // (CommandOk) because the no_std engine owns no WAL / snapshot;
3889        // the host owns both, so we run the real checkpoint here.
3890        // `checkpoint()` reuses the existing snapshot mechanism
3891        // (snapshot_checkpoint_job → worker) and blocks until the
3892        // snapshot + WAL fsync/rotate complete, so once this returns
3893        // the committed state has been flushed to `db_path`. No WAL
3894        // ticket — CHECKPOINT itself writes no WAL record (it is
3895        // classified read-only, see `sql_is_read_only`).
3896        if sql_is_checkpoint(sql) {
3897            self.checkpoint()?;
3898            return Ok((result, None));
3899        }
3900        // r180 — RETURNING DML answers Rows; wal_worthy recognises it
3901        // (the CommandOk-only gate silently dropped those records).
3902        let modified = wal_worthy(&result, sql);
3903        let ticket = self.wal_after_ok(sql, modified)?;
3904        Ok((result, ticket))
3905    }
3906
3907    /// v7.21 (round-12 polish) — post-engine WAL bookkeeping shared
3908    /// by the simple ([`Self::execute_buffered`]) and prepared
3909    /// ([`Self::execute_prepared_buffered`]) write paths. `canonical`
3910    /// is the replay text (bind-final for prepared statements);
3911    /// `modified_catalog` comes from the engine result. Three routes:
3912    ///
3913    /// - transaction control → maintain [`Self::tx_wal`]: BEGIN opens
3914    ///   the buffer, COMMIT flushes it as ONE atomic
3915    ///   `WAL_V4_TYPE_TX_COMMIT_SQL` record, ROLLBACK drops it,
3916    ///   SAVEPOINT / ROLLBACK TO mark / truncate it. The engine has
3917    ///   already accepted the statement, so this only mirrors state.
3918    /// - inside an open transaction → buffer the statement (shadow-
3919    ///   catalog mutations report `modified_catalog: false`, so the
3920    ///   auto-commit arm below can't see them).
3921    /// - auto-commit mutation → classic per-statement v4 record.
3922    ///
3923    /// v7.18 PITR — v4 records carry commit LSN + wall-clock micros.
3924    /// The crash window remains one BATCH: replay re-applies
3925    /// idempotently exactly as before, and a torn batch tail drops
3926    /// cleanly (same torn-write handling).
3927    fn wal_after_ok(
3928        &mut self,
3929        canonical: &str,
3930        modified_catalog: bool,
3931    ) -> Result<Option<WalTicket>, EngineError> {
3932        if self.persistence.is_none() {
3933            return Ok(None);
3934        }
3935        let mut record = None;
3936        match tx_control_kind(canonical) {
3937            Some(TxControl::Begin) => {
3938                self.tx_wal = Some(TxWalBuffer::default());
3939            }
3940            Some(TxControl::Commit) => {
3941                if let Some(buf) = self.tx_wal.take()
3942                    && !buf.statements.is_empty()
3943                {
3944                    let script = buf.statements.join(";\n");
3945                    let lsn = self.commit_lsn.fetch_add(1, Ordering::SeqCst) + 1;
3946                    record = Some(encode_v6_tx_commit(
3947                        &script,
3948                        lsn.saturating_sub(1),
3949                        lsn,
3950                        wall_clock_micros(),
3951                    ));
3952                }
3953            }
3954            Some(TxControl::Rollback) => {
3955                self.tx_wal = None;
3956            }
3957            Some(TxControl::Savepoint(name)) => {
3958                if let Some(buf) = &mut self.tx_wal {
3959                    // PG name-reuse semantics: latest mark wins.
3960                    buf.savepoints.retain(|(n, _)| n != &name);
3961                    let mark = buf.statements.len();
3962                    buf.savepoints.push((name, mark));
3963                }
3964            }
3965            Some(TxControl::RollbackToSavepoint(name)) => {
3966                if let Some(buf) = &mut self.tx_wal
3967                    && let Some(pos) = buf.savepoints.iter().position(|(n, _)| n == &name)
3968                {
3969                    let mark = buf.savepoints[pos].1;
3970                    buf.statements.truncate(mark);
3971                    // Later savepoints die with the rollback; the
3972                    // target itself survives (PG keeps it
3973                    // re-rollbackable).
3974                    buf.savepoints.truncate(pos + 1);
3975                }
3976            }
3977            Some(TxControl::ReleaseSavepoint) => {
3978                // RELEASE folds the savepoint into the enclosing tx —
3979                // buffered statements stay. The mark also stays:
3980                // marks are only consulted by ROLLBACK TO, which the
3981                // engine validates first, so a dangling mark is
3982                // unreachable.
3983            }
3984            None => {
3985                // r180 — `sql_is_read_only` head-words `with` as a
3986                // read, but a writable CTE (`WITH … INSERT/…`) is a
3987                // mutation: it must reach the tx buffer / autocommit
3988                // record or replay silently loses it.
3989                let persistable = |sql: &str| !sql_is_read_only(sql) || sql_is_dmlish(sql);
3990                if let Some(buf) = &mut self.tx_wal {
3991                    if persistable(canonical) {
3992                        buf.statements.push(canonical.to_string());
3993                    }
3994                } else if modified_catalog && persistable(canonical) {
3995                    let lsn = self.commit_lsn.fetch_add(1, Ordering::SeqCst) + 1;
3996                    // v7.34 (crash-recovery P0 #2) — hybrid log: when
3997                    // row-level redo is on and this statement produced row
3998                    // changes (DML), write a physical 0x13 redo record so
3999                    // replay applies it directly. A statement with no row
4000                    // changes (DDL: CREATE/ALTER, never goes through
4001                    // Table::insert/update/delete) drains an empty redo and
4002                    // keeps the SQL record so the schema still replays.
4003                    let redo = if row_redo_enabled() {
4004                        self.engine.take_redo()
4005                    } else {
4006                        Vec::new()
4007                    };
4008                    let prev_lsn = lsn.saturating_sub(1);
4009                    record = Some(if redo.is_empty() {
4010                        encode_v6_auto_commit(canonical, prev_lsn, lsn, wall_clock_micros())
4011                    } else {
4012                        encode_v6_row_redo(
4013                            &spg_storage::encode_redo_log(&redo),
4014                            prev_lsn,
4015                            lsn,
4016                            wall_clock_micros(),
4017                        )
4018                    });
4019                }
4020            }
4021        }
4022        let mut ticket = None;
4023        if let Some(record) = record {
4024            let p = self.persistence.as_mut().expect("checked above");
4025            let seq = p.wal.enqueue(&record);
4026            ticket = Some(WalTicket {
4027                group: Arc::clone(&p.wal),
4028                seq,
4029            });
4030            // v7.37.10 — bytes OR time threshold. The byte path was the
4031            // only trigger pre-v7.37.10 and silently mis-served slow-
4032            // write workloads (mailrs measured 14 h between checkpoints
4033            // at ~30 KB/hr; 4 MiB byte threshold needed 130+ h). Time
4034            // path bounds the data-loss-on-WAL-quarantine window to
4035            // the configured interval (default 60 s).
4036            let bytes_trigger = p.wal.written_len() >= p.checkpoint_threshold_bytes;
4037            let time_trigger = p.checkpoint_time_threshold.is_some_and(|threshold| {
4038                let last = *p
4039                    .last_checkpoint_at
4040                    .lock()
4041                    .unwrap_or_else(|e| e.into_inner());
4042                let last_len = *p
4043                    .last_checkpoint_wal_len
4044                    .lock()
4045                    .unwrap_or_else(|e| e.into_inner());
4046                let now = std::time::Instant::now();
4047                let written_now = p.wal.written_len();
4048                // Only fire if we've actually accumulated writes since the
4049                // last checkpoint (avoid spinning on idle databases).
4050                written_now > last_len && now.duration_since(last) >= threshold
4051            });
4052            if bytes_trigger || time_trigger {
4053                // CoW-2 (v7.34): hot path — fire-and-forget. The worker
4054                // serializes off this thread so the commit that just
4055                // crossed the threshold doesn't stall on a multi-hundred-ms
4056                // snapshot write. Any sticky error from a prior async
4057                // checkpoint surfaces here.
4058                // v7.38 (read01 P5.02) — only advance the checkpoint
4059                // bookkeeping when the job was actually enqueued. If the
4060                // worker dropped it (a prior checkpoint still pending /
4061                // inflight), leaving last_checkpoint_at untouched lets the
4062                // next commit re-trigger so the newer writes still get a
4063                // checkpoint instead of waiting a full interval.
4064                let accepted = self.trigger_checkpoint()?;
4065                if accepted {
4066                    // v7.37.13 (A1.8) — feed the EWMA + recompute the
4067                    // adaptive byte threshold from observed WAL growth
4068                    // rate. Read the prior markers BEFORE we overwrite
4069                    // them so dt_secs / bytes_in_window are correct.
4070                    let p = self.persistence.as_mut().expect("checked above");
4071                    let new_at = std::time::Instant::now();
4072                    let now_len = p.wal.written_len();
4073                    if p.adaptive_threshold_enabled {
4074                        let prev_at = *p
4075                            .last_checkpoint_at
4076                            .lock()
4077                            .unwrap_or_else(|e| e.into_inner());
4078                        let prev_len = *p
4079                            .last_checkpoint_wal_len
4080                            .lock()
4081                            .unwrap_or_else(|e| e.into_inner());
4082                        let dt_secs = new_at.duration_since(prev_at).as_secs_f64().max(0.001);
4083                        let bytes_in_window = now_len.saturating_sub(prev_len);
4084                        let rate = (bytes_in_window as f64 / dt_secs) as u64;
4085                        let mut ewma = p
4086                            .ewma_wal_rate_bytes_per_sec
4087                            .lock()
4088                            .unwrap_or_else(|e| e.into_inner());
4089                        *ewma = if *ewma == 0 {
4090                            rate
4091                        } else {
4092                            // α = 0.3 (current rate weight); 0.7 history.
4093                            (rate.saturating_mul(30) + ewma.saturating_mul(70)) / 100
4094                        };
4095                        let target = ewma.saturating_mul(30); // ~30 s of writes
4096                        drop(ewma);
4097                        // Bound: [1 MiB, 64 MiB] so we never go absurd.
4098                        let bounded = target.max(1024 * 1024).min(64 * 1024 * 1024);
4099                        p.checkpoint_threshold_bytes = bounded;
4100                    }
4101                    *p.last_checkpoint_at
4102                        .lock()
4103                        .unwrap_or_else(|e| e.into_inner()) = new_at;
4104                    *p.last_checkpoint_wal_len
4105                        .lock()
4106                        .unwrap_or_else(|e| e.into_inner()) = now_len;
4107                }
4108            }
4109        }
4110        Ok(ticket)
4111    }
4112
4113    /// v7.3.0 — typed-row variant of [`Database::query`]. Each
4114    /// row decodes into a `T: FromSpgRow` so callers don't
4115    /// pattern-match on `Value` themselves. Use [`spg_row!`] to
4116    /// generate the impl, or write it by hand.
4117    pub fn query_typed<T: FromSpgRow>(&mut self, sql: &str) -> Result<Vec<T>, EngineError> {
4118        let rows = self.query(sql)?;
4119        rows.into_iter().map(|r| T::from_spg_row(&r)).collect()
4120    }
4121
4122    /// Run a SELECT and return rows as a `Vec<Vec<Value>>` —
4123    /// strips the column-schema metadata for read-side
4124    /// ergonomics. Errors on non-Rows results (DML / DDL
4125    /// statements should go through `execute` instead).
4126    pub fn query(&mut self, sql: &str) -> Result<Vec<Vec<Value<'static>>>, EngineError> {
4127        match self.engine.execute(sql)? {
4128            QueryResult::Rows { rows, .. } => Ok(rows.into_iter().map(|r| r.values).collect()),
4129            QueryResult::CommandOk { .. } => Err(EngineError::Unsupported(
4130                "query() expects a SELECT — use execute() for DML/DDL".into(),
4131            )),
4132            // v7.5.0 — QueryResult is #[non_exhaustive]; any future
4133            // variant is not a SELECT row stream, treat as Unsupported.
4134            _ => Err(EngineError::Unsupported(
4135                "query() expects a SELECT — use execute() for DML/DDL".into(),
4136            )),
4137        }
4138    }
4139
4140    /// v7.16.0 — column-aware variant of [`Self::query`].
4141    /// Returns the column schema vec alongside the rows so
4142    /// adapters (the spg-sqlx Row impl most notably) can drive
4143    /// name + type-based column lookups. Errors on non-Rows
4144    /// results identically to `query`.
4145    pub fn query_with_columns(
4146        &mut self,
4147        sql: &str,
4148    ) -> Result<(Vec<spg_storage::ColumnSchema>, Vec<Vec<Value<'static>>>), EngineError> {
4149        match self.engine.execute(sql)? {
4150            QueryResult::Rows { columns, rows } => {
4151                Ok((columns, rows.into_iter().map(|r| r.values).collect()))
4152            }
4153            QueryResult::CommandOk { .. } => Err(EngineError::Unsupported(
4154                "query_with_columns() expects a SELECT — use execute() for DML/DDL".into(),
4155            )),
4156            _ => Err(EngineError::Unsupported(
4157                "query_with_columns() expects a SELECT — use execute() for DML/DDL".into(),
4158            )),
4159        }
4160    }
4161
4162    /// v7.16.0 — column-aware variant of
4163    /// [`Self::query_prepared`]. Same shape as
4164    /// `query_with_columns` but driven from a prepared
4165    /// statement + bound params.
4166    pub fn query_prepared_with_columns(
4167        &mut self,
4168        stmt: &Statement,
4169        params: &[Value<'static>],
4170    ) -> Result<(Vec<spg_storage::ColumnSchema>, Vec<Vec<Value<'static>>>), EngineError> {
4171        match self.engine.execute_prepared(stmt.stmt.clone(), params)? {
4172            QueryResult::Rows { columns, rows } => {
4173                Ok((columns, rows.into_iter().map(|r| r.values).collect()))
4174            }
4175            QueryResult::CommandOk { .. } => Err(EngineError::Unsupported(
4176                "query_prepared_with_columns() expects a SELECT — use execute_prepared() for DML/DDL".into(),
4177            )),
4178            _ => Err(EngineError::Unsupported(
4179                "query_prepared_with_columns() expects a SELECT — use execute_prepared() for DML/DDL".into(),
4180            )),
4181        }
4182    }
4183
4184    /// Borrow the underlying engine. Escape hatch for callers
4185    /// that need access to `spg-engine` APIs not yet surfaced
4186    /// here (transactions, EXPLAIN ANALYZE, etc.).
4187    #[must_use]
4188    pub const fn engine(&self) -> &Engine {
4189        &self.engine
4190    }
4191
4192    /// Mutable borrow of the underlying engine. Same intent as
4193    /// `engine()` but for write-side APIs (e.g. inserting
4194    /// directly through `Catalog::insert` for high-throughput
4195    /// bulk loads that bypass SQL parsing).
4196    pub const fn engine_mut(&mut self) -> &mut Engine {
4197        &mut self.engine
4198    }
4199
4200    /// v7.38 (mailrs prod 7.35 pool-exhaustion incident) — boot-time
4201    /// plan-IR cache warm-up. Pre-prepares the listed SQL shapes so
4202    /// the first user-facing request doesn't pay the 2-3 s
4203    /// first-fire parse + JOIN-reorder cost on the readonly-blocking
4204    /// pool. Recommended call site: `Database::new` immediately after
4205    /// catalog restore, before serving any traffic. Returns the
4206    /// number of statements successfully cached.
4207    pub fn warm_up_plan_cache(&mut self, sqls: &[&str]) -> usize {
4208        self.engine.warm_up_plan_cache(sqls)
4209    }
4210
4211    /// v7.38 (mailrs prod 7.35 pool-exhaustion incident) — boot-time
4212    /// cold-tier OS page-cache warm-up. Touches every cold segment
4213    /// file in the active catalog so the kernel page cache loads
4214    /// them before user traffic arrives. On a hot-only catalog the
4215    /// call is a near-no-op. Returns the total cold rows touched.
4216    pub fn warm_up_cold_tier(&self) -> usize {
4217        self.engine.warm_up_cold_tier()
4218    }
4219
4220    /// v7.16.0 — parse + plan a SQL string ONCE so subsequent
4221    /// `execute_prepared` / `query_prepared` calls can re-bind
4222    /// parameters without re-parsing. The returned [`Statement`]
4223    /// is a thin handle around the AST + cached source SQL; it's
4224    /// `Clone` so the same plan can drive many bind calls
4225    /// concurrently (each call clones the AST and runs
4226    /// placeholder substitution on the clone — the cached
4227    /// plan stays intact).
4228    ///
4229    /// Plan caching follows the engine's existing version-aware
4230    /// rule: a prepared `Statement` whose statistics version
4231    /// has rolled (ANALYZE ran between prepare and execute)
4232    /// will silently re-prepare under the hood. Callers don't
4233    /// need to detect this.
4234    ///
4235    /// Placeholders in the SQL use PG's `$1`, `$2`, … convention.
4236    /// `bind`-time `Value`s are passed as a slice; arity
4237    /// mismatches surface as `EvalError::PlaceholderOutOfRange`
4238    /// at `execute_prepared` time, not here.
4239    ///
4240    /// # Errors
4241    /// Surfaces `EngineError` (parse error / plan rewrite
4242    /// failure) from the underlying `Engine::prepare`.
4243    pub fn prepare(&mut self, sql: &str) -> Result<Statement, EngineError> {
4244        // Use the cached path so repeated prepares of the same
4245        // SQL are O(1). The engine's plan cache stays shared
4246        // across all callers of this Database — a single
4247        // `PgPool`-shaped consumer (or, later, the spg-sqlx
4248        // adapter) prepares once and reaps the win on every bind.
4249        let stmt = self
4250            .engine
4251            .prepare_cached(sql)
4252            .map_err(EngineError::Parse)?;
4253        Ok(Statement {
4254            stmt,
4255            sql: sql.to_string(),
4256        })
4257    }
4258
4259    /// v7.17.0 Phase 3.P0-66 — describe a SQL string without
4260    /// executing. Returns `(parameter_oid_count, output_columns)`
4261    /// where `output_columns` is empty for non-SELECT statements
4262    /// or for SELECT shapes the describe planner can't resolve
4263    /// (JOIN / subquery / unknown table). Wraps
4264    /// `Engine::describe_prepared` so the spg-sqlx bridge can
4265    /// surface PG-shape Describe replies for
4266    /// `sqlx::query!()` compile-time validation.
4267    ///
4268    /// # Errors
4269    /// Propagates parse errors from the underlying prepare path.
4270    pub fn describe(&mut self, sql: &str) -> Result<(Vec<u32>, Vec<ColumnSchema>), EngineError> {
4271        let stmt = self
4272            .engine
4273            .prepare_cached(sql)
4274            .map_err(EngineError::Parse)?;
4275        Ok(self.engine.describe_prepared(&stmt))
4276    }
4277
4278    /// v7.16.0 — execute a prepared statement with bound
4279    /// parameters. Mirrors `Engine::execute_prepared`: clones
4280    /// the AST, substitutes `$1..$N` → `params[0..N-1]`, runs.
4281    ///
4282    /// Persistence (WAL fsync + auto-checkpoint) follows the
4283    /// same rules as `execute(sql)`: mutating statements get a
4284    /// WAL record AFTER the in-memory exec succeeds. The WAL
4285    /// record carries the substituted, bind-final SQL, so
4286    /// replay reconstructs the same row state without needing
4287    /// the original prepared `Statement` to still be alive.
4288    ///
4289    /// # Errors
4290    /// Propagates engine errors. Param arity mismatch surfaces
4291    /// as `EvalError::PlaceholderOutOfRange`.
4292    pub fn execute_prepared(
4293        &mut self,
4294        stmt: &Statement,
4295        params: &[Value<'static>],
4296    ) -> Result<QueryResult, EngineError> {
4297        let (result, ticket) = self.execute_prepared_buffered(stmt, params)?;
4298        if let Some(t) = ticket {
4299            // v7.39 (round 171) — see execute(): session-level
4300            // synchronous_commit gates the durability wait.
4301            if self.session_synchronous_commit() {
4302                t.wait()?;
4303            }
4304        }
4305        Ok(result)
4306    }
4307
4308    /// v7.20 P2 — group-commit variant of
4309    /// [`Database::execute_prepared`]. Same contract as
4310    /// [`Database::execute_buffered`]: mutation + enqueue happen
4311    /// here; the caller waits on the ticket AFTER releasing
4312    /// whatever lock guards this `Database`.
4313    ///
4314    /// # Errors
4315    /// Engine errors propagate unchanged; inline auto-checkpoint
4316    /// may surface IO errors.
4317    pub fn execute_prepared_buffered(
4318        &mut self,
4319        stmt: &Statement,
4320        params: &[Value<'static>],
4321    ) -> Result<(QueryResult, Option<WalTicket>), EngineError> {
4322        let result = self.engine.execute_prepared(stmt.stmt.clone(), params)?;
4323        // r180 — same wal_worthy shape as execute_buffered: RETURNING
4324        // DML answers Rows and must still persist.
4325        let modified = wal_worthy(&result, &stmt.sql);
4326        // WAL persistence on the bind-final SQL. Build the
4327        // canonical Display form by re-printing the
4328        // placeholder-substituted statement (cheap — the AST
4329        // is already in hand from execute_prepared's internal
4330        // clone) so replay's path is identical to the
4331        // simple-query path. v7.21: also when a transaction is
4332        // open — in-tx mutations report `modified_catalog: false`
4333        // but must reach the tx WAL buffer (see `wal_after_ok`).
4334        let mut ticket = None;
4335        if self.persistence.is_some()
4336            && (modified
4337                || (self.tx_wal.is_some()
4338                    && (!sql_is_read_only(&stmt.sql) || sql_is_dmlish(&stmt.sql)))
4339                || tx_control_kind(&stmt.sql).is_some())
4340        {
4341            let mut wal_stmt = stmt.stmt.clone();
4342            crate::wal_render_with_params(&mut wal_stmt, params);
4343            let canonical = format!("{wal_stmt}");
4344            ticket = self.wal_after_ok(&canonical, modified)?;
4345        }
4346        Ok((result, ticket))
4347    }
4348
4349    /// v7.16.0 — run a prepared SELECT with bound params and
4350    /// return rows as `Vec<Vec<Value>>`, matching `query()`
4351    /// shape. SELECTs are read-only so this never writes the
4352    /// WAL.
4353    ///
4354    /// # Errors
4355    /// Returns `Unsupported` if the prepared statement isn't a
4356    /// SELECT (use `execute_prepared` for DML/DDL).
4357    pub fn query_prepared(
4358        &mut self,
4359        stmt: &Statement,
4360        params: &[Value<'static>],
4361    ) -> Result<Vec<Vec<Value<'static>>>, EngineError> {
4362        match self.engine.execute_prepared(stmt.stmt.clone(), params)? {
4363            QueryResult::Rows { rows, .. } => Ok(rows.into_iter().map(|r| r.values).collect()),
4364            QueryResult::CommandOk { .. } => Err(EngineError::Unsupported(
4365                "query_prepared() expects a SELECT — use execute_prepared() for DML/DDL".into(),
4366            )),
4367            _ => Err(EngineError::Unsupported(
4368                "query_prepared() expects a SELECT — use execute_prepared() for DML/DDL".into(),
4369            )),
4370        }
4371    }
4372
4373    /// v7.18 — parse + plan a SQL string against a
4374    /// `CatalogSnapshot`. Mirror of [`Database::prepare`] for the
4375    /// readonly fan-out path: no writer lock taken, no WAL write,
4376    /// no plan-cache mutation. Static-on-`Self` so callers can
4377    /// dispatch against a snapshot without an `&mut Database`
4378    /// borrow — `AsyncReadHandle::prepare` in spg-embedded-tokio
4379    /// is the load-bearing consumer.
4380    ///
4381    /// # Errors
4382    /// Propagates `EngineError::Parse` from the parser.
4383    pub fn prepare_on_snapshot(
4384        snapshot: &CatalogSnapshot,
4385        sql: &str,
4386    ) -> Result<Statement, EngineError> {
4387        let stmt =
4388            spg_engine::Engine::prepare_on_snapshot(snapshot, sql).map_err(EngineError::Parse)?;
4389        Ok(Statement {
4390            stmt,
4391            sql: sql.to_string(),
4392        })
4393    }
4394
4395    /// v7.18 — execute a prepared `Statement` against a
4396    /// `CatalogSnapshot` with bound params. Mirror of
4397    /// [`Database::execute_prepared`] on the readonly path:
4398    /// writes / DDL hit `EngineError::WriteRequired`. No WAL
4399    /// write, no writer lock, multiple snapshots can run
4400    /// concurrently — the snapshot is immutable from prepare time.
4401    ///
4402    /// # Errors
4403    /// Surfaces `EngineError::WriteRequired` for non-readonly
4404    /// statements; propagates other engine errors.
4405    pub fn execute_prepared_on_snapshot(
4406        snapshot: &CatalogSnapshot,
4407        stmt: &Statement,
4408        params: &[Value<'static>],
4409    ) -> Result<QueryResult, EngineError> {
4410        spg_engine::Engine::execute_readonly_prepared_on_snapshot(
4411            snapshot,
4412            stmt.stmt.clone(),
4413            params,
4414        )
4415    }
4416
4417    /// v7.28 (round-22) — deadline-bounded variant of
4418    /// [`Database::execute_prepared_on_snapshot`]. Returns
4419    /// `EngineError::Cancelled` once the budget elapses; the
4420    /// sqlx driver uses this to keep readonly-INLINE execution
4421    /// from monopolising the caller's async runtime (four slow
4422    /// inbox queries saturated mailrs's whole tokio pool) and
4423    /// re-runs over the blocking pool on timeout.
4424    ///
4425    /// # Errors
4426    /// `EngineError::Cancelled` on budget expiry; engine errors
4427    /// otherwise.
4428    pub fn execute_prepared_on_snapshot_with_budget(
4429        snapshot: &CatalogSnapshot,
4430        stmt: &Statement,
4431        params: &[Value<'static>],
4432        budget_us: u64,
4433    ) -> Result<QueryResult, EngineError> {
4434        fn mono_now_us() -> u64 {
4435            use std::time::{SystemTime, UNIX_EPOCH};
4436            // Monotonic enough for a per-call relative budget: the
4437            // engine only compares (now - start) against the budget
4438            // within one call.
4439            SystemTime::now()
4440                .duration_since(UNIX_EPOCH)
4441                .map(|d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX))
4442                .unwrap_or(0)
4443        }
4444        let deadline = mono_now_us().saturating_add(budget_us);
4445        let token = spg_engine::CancelToken::none().with_deadline(mono_now_us, deadline);
4446        spg_engine::Engine::execute_readonly_prepared_on_snapshot_with_cancel(
4447            snapshot,
4448            stmt.stmt.clone(),
4449            params,
4450            token,
4451        )
4452    }
4453
4454    /// v7.18 — describe a SQL string against a
4455    /// `CatalogSnapshot`. Mirror of [`Database::describe`] on
4456    /// the readonly path. Pure function on the snapshot's
4457    /// catalog; safe to call from any thread.
4458    ///
4459    /// # Errors
4460    /// Propagates `EngineError::Parse` from the parser.
4461    pub fn describe_on_snapshot(
4462        snapshot: &CatalogSnapshot,
4463        sql: &str,
4464    ) -> Result<(Vec<u32>, Vec<ColumnSchema>), EngineError> {
4465        let stmt =
4466            spg_engine::Engine::prepare_on_snapshot(snapshot, sql).map_err(EngineError::Parse)?;
4467        Ok(spg_engine::Engine::describe_prepared_on_snapshot(
4468            snapshot, &stmt,
4469        ))
4470    }
4471
4472    /// v7.21 (round-12 polish) — run a multi-statement SQL script
4473    /// with PG simple-query semantics: the statements execute in
4474    /// order inside ONE implicit transaction, so a mid-script error
4475    /// rolls back the whole script (PG wraps every simple-query
4476    /// message in an implicit transaction). Three exceptions, all
4477    /// PG-faithful:
4478    ///
4479    /// - a script that carries its OWN transaction control
4480    ///   (BEGIN / COMMIT / …) runs statement-by-statement — the
4481    ///   script owns its boundaries;
4482    /// - a script run while the caller already has a transaction
4483    ///   open joins that transaction (no nested BEGIN), and the
4484    ///   caller's COMMIT / ROLLBACK decides its fate;
4485    /// - a single-statement script is plain auto-commit.
4486    ///
4487    /// Returns one `QueryResult` per executed statement. This is the
4488    /// engine behind `sqlx::raw_sql` (mailrs feeds whole
4489    /// `init-schema.sql` files through it) and `spgctl import`.
4490    ///
4491    /// # Errors
4492    /// The first failing statement's error propagates after the
4493    /// implicit ROLLBACK; nothing from the script remains applied.
4494    pub fn execute_script(&mut self, sql: &str) -> Result<Vec<QueryResult>, EngineError> {
4495        let stmts = split_statements(sql);
4496        let script_owns_tx = stmts.iter().any(|s| tx_control_kind(s).is_some());
4497        let wrap = stmts.len() > 1 && !script_owns_tx && !self.engine.in_transaction();
4498        if !wrap {
4499            let mut out = Vec::with_capacity(stmts.len());
4500            for stmt in &stmts {
4501                out.push(self.execute_dump_statement(stmt)?);
4502            }
4503            return Ok(out);
4504        }
4505        self.execute("BEGIN")?;
4506        let mut out = Vec::with_capacity(stmts.len());
4507        for stmt in &stmts {
4508            match self.execute_dump_statement(stmt) {
4509                Ok(r) => out.push(r),
4510                Err(e) => {
4511                    // Best-effort rollback; surface the script error.
4512                    let _ = self.execute("ROLLBACK");
4513                    return Err(e);
4514                }
4515            }
4516        }
4517        self.execute("COMMIT")?;
4518        Ok(out)
4519    }
4520
4521    /// v7.22 (round-13 T2) — execute one `split_statements` chunk,
4522    /// lowering a `COPY … FROM stdin;` block (statement + its data
4523    /// lines, as one chunk) to per-row INSERTs through the shared
4524    /// `spg_engine::copy` helpers. Default-format pg_dump emits
4525    /// COPY blocks, so the zero-change import promise needs this on
4526    /// the embed path; non-COPY statements pass straight through to
4527    /// [`Self::execute`]. Public so `spgctl import` can keep its
4528    /// per-statement error indexing while sharing the lowering.
4529    ///
4530    /// # Errors
4531    /// Engine errors propagate; for COPY the failing row's INSERT
4532    /// error carries the synthesized statement context.
4533    pub fn execute_dump_statement(&mut self, stmt: &str) -> Result<QueryResult, EngineError> {
4534        // Strip pg_dump's `-- Data for Name: …;` banner (it carries
4535        // semicolons of its own) before splitting head from data.
4536        let stmt_clean = strip_leading_sql_noise(stmt);
4537        let head_is_copy = stmt_clean
4538            .get(..4)
4539            .is_some_and(|p| p.eq_ignore_ascii_case("copy"));
4540        if head_is_copy
4541            && let Some((head, data)) = stmt_clean.split_once(';')
4542            && let Some(spec) = spg_engine::copy::parse_copy_from_stdin_head(head)
4543        {
4544            let mut affected: usize = 0;
4545            for line in data.lines() {
4546                // Empty fragments only occur at the chunk boundary
4547                // (the remainder of the COPY line right after `;`);
4548                // data rows are whole non-empty lines.
4549                let line = line.strip_suffix('\r').unwrap_or(line);
4550                if line.is_empty() {
4551                    continue;
4552                }
4553                let values = spg_engine::copy::decode_copy_text_row(line);
4554                let insert = spg_engine::copy::build_copy_insert(
4555                    &spec.table,
4556                    spec.columns.as_deref(),
4557                    &values,
4558                );
4559                match self.execute(&insert)? {
4560                    QueryResult::CommandOk { affected: n, .. } => affected += n,
4561                    _ => affected += 1,
4562                }
4563            }
4564            return Ok(QueryResult::CommandOk {
4565                affected,
4566                modified_catalog: false,
4567            });
4568        }
4569        self.execute(stmt)
4570    }
4571
4572    /// v7.2.0 — run `body` inside an implicit `BEGIN` /
4573    /// `COMMIT` pair. The body receives `&mut Database` so it
4574    /// can `execute()` / `query()` like any other code path;
4575    /// the only difference is that every write in the body
4576    /// lands inside one transaction, and a returned `Err` from
4577    /// the body triggers `ROLLBACK` before the error propagates.
4578    ///
4579    /// Nested calls are not supported — SPG's transaction
4580    /// model is single-writer with explicit `BEGIN` /
4581    /// `COMMIT` / `ROLLBACK`, and a nested `with_transaction`
4582    /// would hit `EngineError::Unsupported("nested
4583    /// transaction")` at the inner `BEGIN`.
4584    pub fn with_transaction<R, F>(&mut self, body: F) -> Result<R, EngineError>
4585    where
4586        F: FnOnce(&mut Self) -> Result<R, EngineError>,
4587    {
4588        self.execute("BEGIN")?;
4589        match body(self) {
4590            Ok(value) => {
4591                self.execute("COMMIT")?;
4592                Ok(value)
4593            }
4594            Err(e) => {
4595                // Best-effort rollback. If ROLLBACK itself
4596                // fails (rare — the engine reports it via
4597                // `Unsupported` only when there's no active
4598                // TX, which can't happen here) we surface the
4599                // original body error, not the rollback error.
4600                let _ = self.execute("ROLLBACK");
4601                Err(e)
4602            }
4603        }
4604    }
4605}
4606
4607impl Default for Database {
4608    fn default() -> Self {
4609        Self::open_in_memory()
4610    }
4611}
4612
4613/// v7.7.5 — observability snapshot returned by
4614/// [`Database::metrics`]. Plain data, no allocations beyond
4615/// what the struct itself takes; cheap to construct and
4616/// cheap to serialise.
4617#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4618#[non_exhaustive]
4619pub struct EmbeddedMetrics {
4620    /// Total live row count across every user table (hot
4621    /// tier only — cold-tier rows live in segment files).
4622    pub hot_rows: u64,
4623    /// Sum of `Table::hot_bytes` across every user table.
4624    /// Tracks against the freezer's `hot_tier_bytes` budget.
4625    pub hot_bytes: u64,
4626    /// Number of cold-tier segments registered in the catalog.
4627    /// Includes tombstoned slots (segments retired by
4628    /// compaction whose disk file may still be on disk).
4629    pub cold_segments: u64,
4630    /// User-table count (excludes any future engine-managed
4631    /// internal tables).
4632    pub tables: u64,
4633    /// WAL size at last `execute()` / `checkpoint()`. Zero
4634    /// when the database is in-memory.
4635    pub wal_bytes: u64,
4636    /// `true` when the database was opened with `open_path` —
4637    /// i.e. WAL + checkpoint persistence is active.
4638    pub persistent: bool,
4639}
4640
4641/// v7.37.14 (A2.2) — RAII bump-on-create / dec-on-drop guard for
4642/// the foreground active-query counter. Acquired at the top of
4643/// every `Database::execute*` entry point; the Drop fires on
4644/// normal return AND panic unwind so the counter never leaks.
4645///
4646/// Owns an `Arc<AtomicU32>` (not a borrow) so the guard's
4647/// lifetime is independent of any borrow on the parent Database
4648/// — avoids the "guard borrows &self, body needs &mut self"
4649/// borrowck conflict.
4650struct ActiveQueryGuard {
4651    counter: Arc<core::sync::atomic::AtomicU32>,
4652}
4653
4654impl ActiveQueryGuard {
4655    fn new(counter: &Arc<core::sync::atomic::AtomicU32>) -> Self {
4656        counter.fetch_add(1, core::sync::atomic::Ordering::AcqRel);
4657        Self {
4658            counter: Arc::clone(counter),
4659        }
4660    }
4661}
4662
4663impl Drop for ActiveQueryGuard {
4664    fn drop(&mut self) {
4665        self.counter
4666            .fetch_sub(1, core::sync::atomic::Ordering::AcqRel);
4667    }
4668}
4669
4670/// v7.2.1 — handle returned by `spawn_background_freezer`.
4671/// Drop signals the worker thread to wind down + joins it,
4672/// so a `Database` (or its shared `Arc<Mutex<Database>>`)
4673/// can safely drop after the handle does.
4674#[must_use = "the background freezer keeps running until this handle is dropped"]
4675#[derive(Debug)]
4676pub struct FreezerHandle {
4677    shutdown: Arc<AtomicBool>,
4678    join: Option<JoinHandle<()>>,
4679}
4680
4681impl FreezerHandle {
4682    /// v7.2.1 — request the worker stop + join. Idempotent;
4683    /// safe to call from `Drop` (which also calls it).
4684    pub fn stop(&mut self) {
4685        self.shutdown.store(true, Ordering::Release);
4686        if let Some(h) = self.join.take() {
4687            let _ = h.join();
4688        }
4689    }
4690}
4691
4692impl Drop for FreezerHandle {
4693    fn drop(&mut self) {
4694        self.stop();
4695    }
4696}
4697
4698/// v7.2.1 — knobs for `Database::spawn_background_freezer`.
4699#[derive(Debug, Clone)]
4700pub struct FreezerOptions {
4701    /// Tick interval. Worker wakes every `tick`, checks the
4702    /// catalog's `hot_tier_bytes`, and freezes if over budget.
4703    pub tick: Duration,
4704    /// Hot-tier byte budget. Exceeded → next tick freezes the
4705    /// largest table's oldest `batch_rows` rows into a new
4706    /// cold segment.
4707    pub hot_tier_bytes: u64,
4708    /// Max rows the freezer demotes per fire.
4709    pub batch_rows: usize,
4710    /// v7.7.4 — auto-compact threshold. When the catalog has
4711    /// at least this many cold segments across all tables, the
4712    /// freezer fires a compaction pass after its next freeze.
4713    /// Set to `usize::MAX` to disable auto-compact entirely;
4714    /// the default is `64`, matching the `spg-server` operating
4715    /// point for SPG_COLD_COMPACT_SEGMENT_THRESHOLD.
4716    pub compact_when_segments_exceed: usize,
4717    /// v7.7.4 — target segment size for compaction merges,
4718    /// in bytes. Default 64 MiB, mirroring `spg-server`. Small
4719    /// segments below this size are merge candidates;
4720    /// segments at or above stay untouched.
4721    pub compact_target_bytes: u64,
4722}
4723
4724impl Default for FreezerOptions {
4725    fn default() -> Self {
4726        // Match the `spg-server` freezer's default operating
4727        // point (SPG_HOT_TIER_BYTES = 4 GiB, batch 1000 rows,
4728        // tick every 1 s) so embedded behaviour is predictable
4729        // for operators familiar with the server.
4730        Self {
4731            tick: Duration::from_secs(1),
4732            hot_tier_bytes: 4 * 1024 * 1024 * 1024,
4733            batch_rows: 1000,
4734            compact_when_segments_exceed: 64,
4735            compact_target_bytes: 64 * 1024 * 1024,
4736        }
4737    }
4738}
4739
4740impl Database {
4741    /// v7.7.4 — observe the catalog's cold-segment count.
4742    /// Useful for tests + dashboards that want to verify
4743    /// auto-compaction is firing.
4744    #[must_use]
4745    pub fn cold_segment_count(&self) -> usize {
4746        self.engine.catalog().cold_segment_count()
4747    }
4748
4749    /// v7.7.5 — observability snapshot. Returns a point-in-time
4750    /// view of the engine + persistence counters. Cheap (no
4751    /// locks beyond the existing `&self` borrow), so safe to
4752    /// call from a hot metrics-scrape path.
4753    ///
4754    /// Fields mirror the operational dashboard
4755    /// [`spg-server`](https://crates.io/crates/spg-server) exposes,
4756    /// minus the network counters that don't apply to embedded.
4757    #[must_use]
4758    pub fn metrics(&self) -> EmbeddedMetrics {
4759        let cat = self.engine.catalog();
4760        let mut hot_rows: u64 = 0;
4761        let mut hot_bytes: u64 = 0;
4762        for name in cat.table_names() {
4763            if let Some(t) = cat.get(&name) {
4764                hot_rows = hot_rows.saturating_add(t.row_count() as u64);
4765                hot_bytes = hot_bytes.saturating_add(t.hot_bytes());
4766            }
4767        }
4768        let (wal_bytes, persistent) = match &self.persistence {
4769            Some(p) => (p.wal.written_len(), true),
4770            None => (0, false),
4771        };
4772        EmbeddedMetrics {
4773            hot_rows,
4774            hot_bytes,
4775            cold_segments: cat.cold_segment_count() as u64,
4776            tables: cat.table_count() as u64,
4777            wal_bytes,
4778            persistent,
4779        }
4780    }
4781
4782    /// v7.2.1 — spawn a background thread that periodically
4783    /// runs `freeze_oldest_to_cold` when the catalog-wide hot
4784    /// tier exceeds `opts.hot_tier_bytes`. The `Arc<Mutex<_>>`
4785    /// pattern matches the v7.2 sharing story: callers wrap
4786    /// their `Database` in `Arc::new(Mutex::new(db))` once,
4787    /// then clone the Arc for the worker + for foreground
4788    /// access. Return value is a handle whose `Drop` joins the
4789    /// worker.
4790    ///
4791    /// Picks the freeze target the same way `spg-server`'s
4792    /// freezer does: largest-`hot_bytes` user table with at
4793    /// least one BTree integer-PK index. Tables without a
4794    /// freezable index are skipped silently.
4795    pub fn spawn_background_freezer(
4796        db: Arc<Mutex<Database>>,
4797        opts: FreezerOptions,
4798    ) -> FreezerHandle {
4799        let shutdown = Arc::new(AtomicBool::new(false));
4800        let shutdown_for_thread = Arc::clone(&shutdown);
4801        let join = thread::Builder::new()
4802            .name("spg-embedded-freezer".into())
4803            .spawn(move || {
4804                background_freezer_loop(db, opts, shutdown_for_thread);
4805            })
4806            .expect("spawn background freezer thread");
4807        FreezerHandle {
4808            shutdown,
4809            join: Some(join),
4810        }
4811    }
4812}
4813
4814/// v7.2.1 — the freezer's main loop, factored out so the
4815/// `Database::spawn_background_freezer` path stays readable.
4816fn background_freezer_loop(
4817    db: Arc<Mutex<Database>>,
4818    opts: FreezerOptions,
4819    shutdown: Arc<AtomicBool>,
4820) {
4821    // Sleep in short slices so a shutdown request resolves
4822    // quickly (vs sleeping the full tick).
4823    let slice = Duration::from_millis(50.min(opts.tick.as_millis() as u64));
4824    // v7.37.14 (A2.2) — capture the foreground active-query
4825    // counter handle so we can poll for contention without
4826    // acquiring db.lock() (no chicken-egg between freezer + user
4827    // queue). The Arc keeps the counter alive even if the
4828    // Database is dropped mid-loop; we cleanly exit when the
4829    // shutdown flag flips.
4830    let active_query_count = {
4831        let Ok(g) = db.lock() else { return };
4832        g.active_query_count_handle()
4833    };
4834    let mut last_tick = std::time::Instant::now();
4835    loop {
4836        if shutdown.load(Ordering::Acquire) {
4837            return;
4838        }
4839        thread::sleep(slice);
4840        if last_tick.elapsed() < opts.tick {
4841            continue;
4842        }
4843        // v7.37.14 (A2.2) — adaptive yield: if foreground queries
4844        // are in flight, sleep another tick BEFORE attempting the
4845        // db.lock() acquire. Matches PG's autovacuum-cost-based
4846        // delay posture (the autovacuum worker pauses when user
4847        // backends are active) but adapts to live load rather
4848        // than relying on a fixed GUC.
4849        let active = active_query_count.load(core::sync::atomic::Ordering::Acquire);
4850        if active > 0 {
4851            // Skip this tick; user threads have priority. Counter
4852            // bumped via Database::active_query_count_handle so
4853            // the read is lock-free.
4854            last_tick = std::time::Instant::now();
4855            continue;
4856        }
4857        last_tick = std::time::Instant::now();
4858        let Ok(mut guard) = db.lock() else {
4859            return;
4860        };
4861        if guard.engine.catalog().hot_tier_bytes() <= opts.hot_tier_bytes {
4862            continue;
4863        }
4864        let Some((table, index)) = pick_freeze_target(&guard) else {
4865            continue;
4866        };
4867        let row_count = guard
4868            .engine
4869            .catalog()
4870            .get(&table)
4871            .map_or(0, spg_storage::Table::row_count);
4872        let to_freeze = opts.batch_rows.min(row_count);
4873        if to_freeze == 0 {
4874            continue;
4875        }
4876        if let Err(e) = guard.freeze_oldest_to_cold(&table, &index, to_freeze) {
4877            eprintln!("spg-embedded: background freeze on {table}.{index} failed: {e:?}");
4878            continue;
4879        }
4880        // v7.7.4 — auto-compact. If the catalog now carries
4881        // more cold segments than the configured threshold,
4882        // run a single compaction pass. Failures are reported
4883        // but don't kill the loop; the next tick will retry.
4884        let count = guard.engine.catalog().cold_segment_count();
4885        if count > opts.compact_when_segments_exceed {
4886            if let Err(e) = guard
4887                .engine
4888                .compact_cold_segments_with_target(opts.compact_target_bytes)
4889            {
4890                eprintln!(
4891                    "spg-embedded: background compact failed (segments={count}, \
4892                     threshold={}): {e:?}",
4893                    opts.compact_when_segments_exceed,
4894                );
4895            }
4896        }
4897    }
4898}
4899
4900/// v7.2.1 — pick the highest-`hot_bytes` user table with a
4901/// BTree integer-PK index. Returns `(table, index_name)` so the
4902/// caller can dispatch through `freeze_oldest_to_cold`.
4903fn pick_freeze_target(db: &Database) -> Option<(String, String)> {
4904    let cat = db.engine.catalog();
4905    let mut best: Option<(String, String, u64)> = None;
4906    for name in cat.table_names() {
4907        let Some(t) = cat.get(&name) else { continue };
4908        if t.row_count() == 0 {
4909            continue;
4910        }
4911        let cols = &t.schema().columns;
4912        let Some(idx) = t.indices().iter().find(|i| {
4913            matches!(i.kind, spg_storage::IndexKind::BTree(_))
4914                && i.column_position < cols.len()
4915                && matches!(
4916                    cols[i.column_position].ty,
4917                    spg_storage::DataType::SmallInt
4918                        | spg_storage::DataType::Int
4919                        | spg_storage::DataType::BigInt
4920                )
4921        }) else {
4922            continue;
4923        };
4924        let hot = t.hot_bytes();
4925        match best {
4926            None => best = Some((name, idx.name.clone(), hot)),
4927            Some((_, _, best_hot)) if hot > best_hot => {
4928                best = Some((name, idx.name.clone(), hot));
4929            }
4930            _ => {}
4931        }
4932    }
4933    best.map(|(t, i, _)| (t, i))
4934}
4935
4936/// v7.7.6 — replay the first `to_seq` records of the WAL at
4937/// `wal_path` into a fresh engine and write the resulting
4938/// catalog snapshot to `out_db_path`. Same semantics as
4939/// `spg revert --wal … --to-seq N --out …` from the CLI:
4940///
4941///   - `to_seq == 0` → snapshot is the empty catalog
4942///   - WAL records beyond `to_seq` are not applied
4943///   - durability-checkpoint markers (v3 type 0x02) are
4944///     consumed without counting against the budget
4945///
4946/// Returns the number of statements actually applied
4947/// (`≤ to_seq`). The output snapshot is byte-identical to
4948/// what `Database::open_path(out_db_path)` would consume on
4949/// a subsequent open.
4950///
4951/// This is the "rewind" operator for an embedded database
4952/// that has been corrupted by a poison statement or a
4953/// half-applied migration. Pair with `cold_segment_paths`
4954/// preservation if your cold-tier files are still on disk.
4955///
4956/// # Errors
4957///
4958/// - `wal_path` unreadable or truncated mid-record
4959/// - WAL record decodes to invalid UTF-8 SQL
4960/// - WAL record's SQL is rejected by the engine
4961/// - `out_db_path` unwritable
4962pub fn revert_wal_to_seq(
4963    wal_path: impl AsRef<Path>,
4964    to_seq: u64,
4965    out_db_path: impl AsRef<Path>,
4966) -> Result<u64, EngineError> {
4967    // v7.19 — accept either a single-file legacy WAL (v7.18 and
4968    // earlier layout) or a chunked WAL directory (v7.19+). For a
4969    // directory, concatenate every `.wal` chunk in sorted order
4970    // — the same order open_path replays them in — so revert
4971    // sees the full record stream.
4972    let path = wal_path.as_ref();
4973    let wal_bytes = if path.is_dir() {
4974        let mut combined = Vec::new();
4975        let chunks = sorted_wal_chunks(path).map_err(io_err)?;
4976        for chunk in chunks {
4977            let bytes = std::fs::read(&chunk).map_err(io_err)?;
4978            combined.extend_from_slice(&bytes);
4979        }
4980        combined
4981    } else {
4982        std::fs::read(path).map_err(io_err)?
4983    };
4984    // v7.37.8 — switched from `decode_wal_record` (V1-V3 SQL-only) to
4985    // `parse_wal_records` + per-type dispatch, mirroring
4986    // `replay_wal_filtered`. The pre-v7.37.8 path silently mis-parsed
4987    // V4/V5 framed records as "truncated" because their length
4988    // headers carry the V2_SENTINEL / V3_FLAG bits that
4989    // `decode_wal_record`'s legacy header-decode never strips. v7.37.8
4990    // flips `SPG_WAL_ROW_REDO` default ON, so freshly written WALs
4991    // are V5 ROW_REDO; the PITR utility must understand them too.
4992    let mut engine = Engine::new();
4993    let mut applied = 0u64;
4994    let records = parse_wal_records(&wal_bytes)
4995        .map_err(|m| EngineError::Storage(spg_storage::StorageError::Corrupt(m)))?;
4996    for r in &records {
4997        if applied >= to_seq {
4998            break;
4999        }
5000        // Markers don't count toward the seq budget — they're metadata.
5001        if r.type_byte == WAL_V3_TYPE_DURABILITY_CHECKPOINT
5002            || r.type_byte == WAL_V4_TYPE_CHECKPOINT_MARKER
5003        {
5004            continue;
5005        }
5006        if r.type_byte == WAL_V5_TYPE_ROW_REDO {
5007            let changes = spg_storage::decode_redo_log(r.sql).map_err(|e| {
5008                EngineError::Storage(spg_storage::StorageError::Corrupt(format!(
5009                    "PITR: redo decode at offset {}: {e:?}",
5010                    r.offset
5011                )))
5012            })?;
5013            engine.apply_redo(&changes)?;
5014            applied += 1;
5015            continue;
5016        }
5017        // V1-V3 (legacy SQL) and V4 AUTO_COMMIT_SQL / TX_COMMIT_SQL —
5018        // re-execute the SQL payload.
5019        let sql = core::str::from_utf8(r.sql).map_err(|e| {
5020            EngineError::Storage(spg_storage::StorageError::Corrupt(format!(
5021                "PITR: WAL record at offset {}: non-UTF-8 SQL: {e}",
5022                r.offset
5023            )))
5024        })?;
5025        for stmt in split_statements(sql) {
5026            engine.execute(stmt)?;
5027        }
5028        applied += 1;
5029    }
5030    let snapshot = engine.snapshot();
5031    std::fs::write(out_db_path.as_ref(), &snapshot).map_err(io_err)?;
5032    Ok(applied)
5033}
5034
5035/// v7.7.6 — decode one WAL record from a byte tail. Returns
5036/// `(sql_bytes, header_plus_payload_len)`. Handles the three
5037/// on-disk formats (v1 / v2 / v3) the same way the CLI
5038/// `decode_one_record` and the engine's `replay_wal_bytes`
5039/// do. CRCs are not re-validated; the caller's intent is
5040/// "apply", not "validate".
5041fn decode_wal_record(tail: &[u8]) -> Result<(Vec<u8>, usize), EngineError> {
5042    if tail.len() < 4 {
5043        return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5044            format!("WAL truncated record: {} < 4 header bytes", tail.len()),
5045        )));
5046    }
5047    let raw_len = u32::from_le_bytes(tail[..4].try_into().unwrap());
5048    let is_v2 = raw_len & WAL_V2_SENTINEL != 0;
5049    let is_v3 = is_v2 && (raw_len & WAL_V3_FLAG != 0);
5050    let is_v6 = is_v3 && (raw_len & WAL_V6_FLAG != 0);
5051    // v7.37.13 (A1.2) — v6 dispatch via the shared parser.
5052    if is_v6 {
5053        let len_mask = !(WAL_V2_SENTINEL | WAL_V3_FLAG | WAL_V6_FLAG);
5054        let rec_len = (raw_len & len_mask) as usize;
5055        match parse_v6_record_body(tail, 0, rec_len) {
5056            Err(e) => {
5057                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(e)));
5058            }
5059            Ok((view, total)) => {
5060                return Ok((view.payload.to_vec(), total));
5061            }
5062        }
5063    }
5064    let len_mask = if is_v3 {
5065        !(WAL_V2_SENTINEL | WAL_V3_FLAG)
5066    } else {
5067        !WAL_V2_SENTINEL
5068    };
5069    let rec_len = (raw_len & len_mask) as usize;
5070    let header_len = if is_v3 {
5071        9
5072    } else if is_v2 {
5073        8
5074    } else {
5075        4
5076    };
5077    if tail.len() < header_len + rec_len {
5078        return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5079            format!(
5080                "WAL truncated record: header+payload {} > available {}",
5081                header_len + rec_len,
5082                tail.len()
5083            ),
5084        )));
5085    }
5086    if is_v3 {
5087        let type_byte = tail[8];
5088        // v3 type 0x01 = auto_commit_sql (payload = SQL).
5089        // v3 type 0x02 = durability marker (no SQL to apply).
5090        // v4 type 0x10 = auto_commit_sql with 16-byte (lsn, ts)
5091        //                prefix between type and SQL — strip
5092        //                the prefix so the caller still sees raw
5093        //                SQL bytes.
5094        // Anything else is unknown.
5095        if type_byte == WAL_V3_TYPE_AUTO_COMMIT_SQL {
5096            let payload = &tail[header_len..header_len + rec_len];
5097            return Ok((payload.to_vec(), header_len + rec_len));
5098        }
5099        if type_byte == WAL_V4_TYPE_AUTO_COMMIT_SQL || type_byte == WAL_V4_TYPE_TX_COMMIT_SQL {
5100            let v4_total = header_len + WAL_V4_EXTRA_HEADER + rec_len;
5101            if tail.len() < v4_total {
5102                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
5103                    format!(
5104                        "WAL truncated v4 record: header+payload {v4_total} > available {}",
5105                        tail.len()
5106                    ),
5107                )));
5108            }
5109            let sql_start = header_len + WAL_V4_EXTRA_HEADER;
5110            let sql_bytes = tail[sql_start..sql_start + rec_len].to_vec();
5111            return Ok((sql_bytes, v4_total));
5112        }
5113        // Caller treats empty payload as a skip-marker.
5114        return Ok((Vec::new(), header_len + rec_len));
5115    }
5116    let payload = &tail[header_len..header_len + rec_len];
5117    Ok((payload.to_vec(), header_len + rec_len))
5118}
5119
5120impl Drop for Database {
5121    fn drop(&mut self) {
5122        // v7.1 — best-effort final checkpoint when a persistent
5123        // Database leaves scope. Failures here go to stderr so
5124        // operators see them, but Drop can't propagate errors —
5125        // the WAL itself is already durable, so a checkpoint
5126        // miss only means the next boot replays a few more
5127        // records than strictly necessary.
5128        if self.persistence.is_some() {
5129            if let Err(e) = self.checkpoint() {
5130                eprintln!(
5131                    "spg-embedded: final checkpoint on Drop failed: {e:?} \
5132                     (WAL is intact; next open_path will replay)"
5133                );
5134            }
5135        }
5136        // v7.19 P3 / v7.20 — signal the retention + flusher
5137        // threads to exit, then wait for them. Done BEFORE the
5138        // lock release so background threads don't outlive the
5139        // database handle. The flusher drains the pending batch
5140        // on its way out (final flush_now in the thread body),
5141        // so `SPG_SYNCHRONOUS_COMMIT=off` never loses confirmed
5142        // commits across a clean shutdown.
5143        if let Some(ctx) = self.persistence.as_mut() {
5144            if let Some(shutdown) = ctx.retention_shutdown.take() {
5145                shutdown.store(true, Ordering::SeqCst);
5146            }
5147            if let Some(handle) = ctx.retention_thread.take() {
5148                let _ = handle.join();
5149            }
5150            if let Some(shutdown) = ctx.flusher_shutdown.take() {
5151                shutdown.store(true, Ordering::SeqCst);
5152            }
5153            if let Some(handle) = ctx.flusher_thread.take() {
5154                let _ = handle.join();
5155            }
5156            // CoW-2 (v7.34) — final checkpoint above left the worker
5157            // idle; explicitly drop it here so its shutdown signal +
5158            // thread join happens with a deterministic ordering (before
5159            // the lock release / persistence drop), not whenever Rust
5160            // happens to drop the PersistenceCtx fields.
5161            ctx.checkpoint_worker = None;
5162        }
5163        // v7.17.0 Phase 6.2 — release the cross-process lock on
5164        // clean shutdown. Failure is logged but never panics;
5165        // the operator can clear a stale lock via
5166        // `Database::force_unlock` if a crash kept the
5167        // directory around.
5168        if let Some(ctx) = &self.persistence
5169            && ctx.lock_path.exists()
5170        {
5171            // remove_dir_all: the lock dir carries the owner-pid
5172            // record since round-12.
5173            if let Err(e) = std::fs::remove_dir_all(&ctx.lock_path) {
5174                eprintln!(
5175                    "spg-embedded: lock release on Drop failed for {}: {e:?}",
5176                    ctx.lock_path.display()
5177                );
5178            }
5179        }
5180    }
5181}
5182
5183impl Database {
5184    /// v7.17.0 Phase 6.2 — clear a stale cross-process lock.
5185    /// Use when a previous process crashed mid-session and
5186    /// left `<db_path>.lock` behind. Operators should confirm
5187    /// no other process is currently using the database before
5188    /// calling this — SPG cannot fingerprint stale-vs-live
5189    /// without a libc dep, which would violate spg-embedded's
5190    /// zero-deps charter.
5191    pub fn force_unlock(db_path: impl AsRef<Path>) -> Result<(), EngineError> {
5192        let lock_path = {
5193            let mut p = db_path.as_ref().to_path_buf();
5194            let name = p
5195                .file_name()
5196                .map(|n| {
5197                    let mut s = n.to_os_string();
5198                    s.push(".lock");
5199                    s
5200                })
5201                .unwrap_or_else(|| std::ffi::OsString::from(".lock"));
5202            p.set_file_name(name);
5203            p
5204        };
5205        // v7.37.5 (mailrs crash-recovery Ask 2) — also clear the
5206        // in-process registry entry for this lock_path. The operator
5207        // calling `force_unlock` asserts "no one is using this catalog;
5208        // nuke the lock"; the in-process registry would otherwise
5209        // keep an in-flight sibling `Database::open_path` task
5210        // registered, and a same-process retry post-force_unlock
5211        // would refuse honestly with the Ask 1 in-flight error
5212        // even though the operator just declared the catalog free.
5213        // Drop the registry entry before the disk lock so retries
5214        // see a consistent "free" state. The orphaned in-flight
5215        // task, if any, will surface its own error when it tries
5216        // to release the now-vanished lock dir; that's the
5217        // single-instance contract `force_unlock` documents.
5218        {
5219            let mut set = active_open_paths()
5220                .lock()
5221                .unwrap_or_else(|e| e.into_inner());
5222            set.remove(&lock_path);
5223        }
5224        if !lock_path.exists() {
5225            return Ok(());
5226        }
5227        std::fs::remove_dir_all(&lock_path).map_err(io_err)
5228    }
5229}
5230
5231/// v7.1 — turn a `std::io::Error` into the workspace's
5232/// `EngineError` shape. `EngineError::Storage(Corrupt(_))` is
5233/// the closest existing variant — io failures during boot or
5234/// during a WAL append surface as a storage-layer fault to
5235/// callers, which keeps the public error enum unchanged.
5236fn io_err(e: std::io::Error) -> EngineError {
5237    EngineError::Storage(spg_storage::StorageError::Corrupt(format!("io: {e}")))
5238}
5239
5240/// v7.2.2 — `Database` is `Send`, so the recommended sharing
5241/// pattern for multi-threaded callers is `Arc<Mutex<Database>>`:
5242///
5243/// ```no_run
5244/// use std::sync::{Arc, Mutex};
5245/// use spg_embedded::Database;
5246///
5247/// let db = Database::open_in_memory();
5248/// let shared = Arc::new(Mutex::new(db));
5249/// let shared_for_worker = Arc::clone(&shared);
5250/// std::thread::spawn(move || {
5251///     let mut guard = shared_for_worker.lock().unwrap();
5252///     guard.execute("INSERT INTO t VALUES (1)").unwrap();
5253/// });
5254/// ```
5255///
5256/// Internal `RwLock`-wrapped state — letting many threads
5257/// hold concurrent `&Database` for `SELECT` without contending
5258/// — is parked as STABILITY § "Out of v7.2"; multi-reader
5259/// embedded throughput needs a planner-side change to release
5260/// the engine read lock between scans, which is the v7.x
5261/// "Choice A" line of work already documented in v6.9.1's
5262/// carve-out.
5263#[allow(dead_code)]
5264fn _database_is_send() {
5265    fn assert_send<T: Send>() {}
5266    assert_send::<Database>();
5267}
5268
5269/// v6.10.3 — trait that maps a row's columns onto a user
5270/// struct's fields. v7.3.0 ships the [`spg_row!`] declarative
5271/// macro that generates `impl FromSpgRow for YourStruct` from
5272/// a struct definition (no proc-macro, no syn/quote/
5273/// proc-macro2 deps — the workspace's "0 external deps"
5274/// policy holds).
5275///
5276/// Implementors map a row's columns onto a user struct's
5277/// fields. Errors surface as `EngineError::Unsupported` so the
5278/// caller's error type stays uniform.
5279pub trait FromSpgRow: Sized {
5280    /// Decode one query result row into `Self`. Called once per
5281    /// row by [`Database::query_typed`]. The slice length equals
5282    /// the number of columns in the SELECT projection.
5283    fn from_spg_row(row: &[Value]) -> Result<Self, EngineError>;
5284}
5285
5286/// v7.3.0 — declarative macro that generates `FromSpgRow` impl
5287/// for a user struct. Avoids proc-macro deps
5288/// (syn/quote/proc-macro2) so the workspace's 0-deps policy
5289/// holds; the trade-off vs `#[derive(SpgRow)]` is that the
5290/// macro takes the entire struct definition (fields + types)
5291/// as input rather than annotating an existing struct.
5292///
5293/// ```no_run
5294/// use spg_embedded::{Database, spg_row, FromSpgRow};
5295///
5296/// spg_row! {
5297///     pub struct User {
5298///         pub id: i32,
5299///         pub name: String,
5300///     }
5301/// }
5302///
5303/// let mut db = Database::open_in_memory();
5304/// db.execute("CREATE TABLE users (id INT NOT NULL, name TEXT)").unwrap();
5305/// db.execute("INSERT INTO users VALUES (1, 'alice')").unwrap();
5306/// let users: Vec<User> = db.query_typed("SELECT id, name FROM users").unwrap();
5307/// ```
5308///
5309/// Supported field types: `i16`, `i32`, `i64`, `f32`, `f64`,
5310/// `bool`, `String`, `Vec<f32>` (for `VECTOR(N)` columns),
5311/// `Option<T>` of any of the above.
5312#[macro_export]
5313macro_rules! spg_row {
5314    (
5315        $(#[$meta:meta])*
5316        $vis:vis struct $name:ident {
5317            $(
5318                $(#[$fmeta:meta])*
5319                $fvis:vis $field:ident : $ty:ty,
5320            )*
5321        }
5322    ) => {
5323        $(#[$meta])*
5324        #[derive(Debug, Clone)]
5325        $vis struct $name {
5326            $(
5327                $(#[$fmeta])*
5328                $fvis $field : $ty,
5329            )*
5330        }
5331
5332        impl $crate::FromSpgRow for $name {
5333            fn from_spg_row(row: &[$crate::Value]) -> ::core::result::Result<Self, $crate::EngineError> {
5334                let mut __spg_row_iter = row.iter();
5335                $(
5336                    let $field: $ty = {
5337                        let v = __spg_row_iter
5338                            .next()
5339                            .ok_or_else(|| $crate::EngineError::Unsupported(
5340                                ::std::format!(
5341                                    "spg_row! {}: missing column for field `{}`",
5342                                    ::core::stringify!($name),
5343                                    ::core::stringify!($field)
5344                                )
5345                            ))?;
5346                        <$ty as $crate::FromSpgValue>::from_spg_value(v)
5347                            .map_err(|e| $crate::EngineError::Unsupported(
5348                                ::std::format!(
5349                                    "spg_row! {}: column `{}`: {}",
5350                                    ::core::stringify!($name),
5351                                    ::core::stringify!($field),
5352                                    e
5353                                )
5354                            ))?
5355                    };
5356                )*
5357                Ok(Self { $($field,)* })
5358            }
5359        }
5360    };
5361}
5362
5363/// v7.3.0 — per-column decoder used by `spg_row!`. Surface
5364/// covers every numeric / text / bytes / bool variant in
5365/// `Value`, plus `Option<T>` for nullable columns.
5366pub trait FromSpgValue: Sized {
5367    /// Decode one cell into `Self`. The returned `&'static str`
5368    /// is a short diagnostic for type mismatches (e.g. `"expected
5369    /// integer, got TEXT"`); callers wrap it into their own
5370    /// error type.
5371    fn from_spg_value(v: &Value) -> Result<Self, &'static str>;
5372}
5373
5374macro_rules! impl_from_value_int {
5375    ($($t:ty),* $(,)?) => {
5376        $(
5377            impl FromSpgValue for $t {
5378                fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
5379                    match v {
5380                        Value::SmallInt(n) => <$t>::try_from(*n).map_err(|_| "SmallInt does not fit target int type"),
5381                        Value::Int(n)      => <$t>::try_from(*n).map_err(|_| "Int does not fit target int type"),
5382                        Value::BigInt(n)   => <$t>::try_from(*n).map_err(|_| "BigInt does not fit target int type"),
5383                        Value::Null        => Err("NULL in non-Option int column"),
5384                        _ => Err("non-integer value in int column"),
5385                    }
5386                }
5387            }
5388        )*
5389    };
5390}
5391impl_from_value_int!(i16, i32, i64);
5392
5393impl FromSpgValue for f32 {
5394    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
5395        match v {
5396            Value::Float(f) => Ok(*f as f32),
5397            Value::Null => Err("NULL in non-Option float column"),
5398            _ => Err("non-float value in float column"),
5399        }
5400    }
5401}
5402
5403impl FromSpgValue for f64 {
5404    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
5405        match v {
5406            Value::Float(f) => Ok(*f),
5407            Value::Null => Err("NULL in non-Option float column"),
5408            _ => Err("non-float value in float column"),
5409        }
5410    }
5411}
5412
5413impl FromSpgValue for bool {
5414    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
5415        match v {
5416            Value::Bool(b) => Ok(*b),
5417            Value::Null => Err("NULL in non-Option bool column"),
5418            _ => Err("non-bool value in bool column"),
5419        }
5420    }
5421}
5422
5423impl FromSpgValue for String {
5424    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
5425        match v {
5426            Value::Text(s) => Ok(s.to_string()),
5427            Value::Null => Err("NULL in non-Option text column"),
5428            _ => Err("non-text value in String column"),
5429        }
5430    }
5431}
5432
5433impl FromSpgValue for Vec<f32> {
5434    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
5435        match v {
5436            Value::Vector(xs) => Ok(xs.to_vec()),
5437            Value::Null => Err("NULL in non-Option vector column"),
5438            _ => Err("non-vector value in Vec<f32> column"),
5439        }
5440    }
5441}
5442
5443impl<T: FromSpgValue> FromSpgValue for Option<T> {
5444    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
5445        match v {
5446            Value::Null => Ok(None),
5447            other => T::from_spg_value(other).map(Some),
5448        }
5449    }
5450}
5451
5452/// Acquire the cross-process exclusion lock at `lock_path` (atomic
5453/// `mkdir`), recording the owner pid inside. If the lock already
5454/// exists, read the recorded pid and probe liveness — a lock left
5455/// behind by a killed process (docker SIGKILL, crash) is reclaimed
5456/// automatically instead of forcing the operator to delete it by
5457/// hand (mailrs embed round-12: a restarted server came up in
5458/// degraded mode because the previous instance's lock survived).
5459/// v7.27 (mailrs round-21 B) — the prober's environment identity:
5460/// `(hostname, boot-or-container id)`. A pid is only meaningful
5461/// inside the PID namespace that recorded it; mailrs's recovery
5462/// window saw "locked by pid 1" from a STOPPED container because
5463/// the prober's pid 1 (its own init) was alive. When the lock's
5464/// identity differs from ours, liveness is UNDECIDABLE and we
5465/// refuse honestly instead of guessing in either direction.
5466fn host_identity() -> (String, String) {
5467    let hostname = std::process::Command::new("hostname")
5468        .output()
5469        .ok()
5470        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5471        .unwrap_or_default();
5472    // Linux boot id; containers share the host kernel's boot id, so
5473    // hostname (= container id by default) is the namespace
5474    // discriminator and boot id catches host reboots / pid reuse.
5475    let boot_id = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
5476        .map(|s| s.trim().to_string())
5477        .or_else(|_| {
5478            std::process::Command::new("sysctl")
5479                .args(["-n", "kern.bootsessionuuid"])
5480                .output()
5481                .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5482        })
5483        .unwrap_or_default();
5484    (hostname, boot_id)
5485}
5486
5487/// v7.34 (crash-recovery P0 #2) — process start-time, to tell a reused
5488/// pid apart from a genuinely-held lock. In a container the holder is
5489/// always pid 1; `docker start` reuses the container so the NEW process
5490/// is pid 1 too, on the same host+boot id — a bare `pid_alive(1)` probe
5491/// (`ps -p 1` always succeeds) reads a dead owner's lock as live and the
5492/// engine self-deadlocks on its own catalog. The `(pid, start-time)`
5493/// pair is unique per live process within a boot: a reused pid carries a
5494/// LATER start-time, so a mismatch means the recorded owner is gone.
5495/// Linux reads `/proc/<pid>/stat` field 22 (clock ticks since boot);
5496/// `comm` (field 2) is parenthesised and may contain spaces, so fields
5497/// are taken after the LAST ')'. Other platforms return None and the
5498/// liveness check falls back to pid-alive + the self-pid reclaim. Pure
5499/// std — no libc.
5500#[cfg(target_os = "linux")]
5501fn process_start_time(pid: u32) -> Option<String> {
5502    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
5503    let after = stat.rsplit_once(')').map(|(_, rest)| rest)?;
5504    // After comm: state(1) ppid(2) … starttime is the 20th token.
5505    after.split_whitespace().nth(19).map(str::to_string)
5506}
5507
5508#[cfg(not(target_os = "linux"))]
5509fn process_start_time(_pid: u32) -> Option<String> {
5510    None
5511}
5512
5513/// v7.37.5 (mailrs crash-recovery Ask 1) — in-process registry of
5514/// lock paths currently being opened or held by a live `Database`
5515/// instance in THIS process. Closes the v7.37.10 design gap that
5516/// kept the mailrs lock-hang alive across recurrences:
5517///
5518/// `AsyncDatabase::open_path` runs `Database::open_path` inside
5519/// `tokio::task::spawn_blocking`, which CANNOT be cancelled
5520/// mid-flight. When the awaiting future is dropped (pool
5521/// acquire-timeout, ctrl-c on a slow boot, etc.), the blocking
5522/// task keeps running and STILL HOLDS the lock. A concurrent
5523/// retry then reads the on-disk lock, sees `(pid, start-time)`
5524/// matching its OWN process, and the pid-1 + start-time logic
5525/// declares the lock "owner_alive=true" — refusing to reclaim a
5526/// lock that is, in fact, held by a sibling task in the same
5527/// process. Result: every retry hangs until the in-flight open
5528/// completes (≥ 27 min on the 1.5 MB mailrs WAL before Ask 3).
5529///
5530/// The on-disk identity (pid + start-time + hostname + boot id)
5531/// is sufficient ACROSS processes but ambiguous WITHIN one
5532/// process; this set settles it directly. `acquire_path_lock`
5533/// consults the set first: if the path is present, the on-disk
5534/// lock is held by a live sibling task and we refuse honestly
5535/// without reading the pid file. If absent, a same-pid on-disk
5536/// lock is necessarily a previous-generation orphan (the prior
5537/// holder dropped its `LockRegistryGuard` on Drop, so the set
5538/// no longer contains the path) and the existing pid-1 / stale
5539/// reclaim path handles it.
5540fn active_open_paths() -> &'static std::sync::Mutex<std::collections::HashSet<PathBuf>> {
5541    use std::sync::OnceLock;
5542    static ACTIVE: OnceLock<std::sync::Mutex<std::collections::HashSet<PathBuf>>> = OnceLock::new();
5543    ACTIVE.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
5544}
5545
5546/// RAII guard that registers a `lock_path` in `ACTIVE_OPEN_PATHS`
5547/// on construction and de-registers on Drop. Construction fails
5548/// with `EngineError::Unsupported` when the path is already
5549/// present — that's the v7.37.5 honest refusal for a sibling
5550/// in-flight `Database::open_path` on the same path. Carried by
5551/// `Database` for the live duration of the handle so concurrent
5552/// open attempts see the registration even while the prior open's
5553/// `spawn_blocking` task is still in WAL replay.
5554#[derive(Debug)]
5555pub(crate) struct LockRegistryGuard {
5556    path: PathBuf,
5557}
5558
5559impl LockRegistryGuard {
5560    fn try_acquire(lock_path: &Path) -> Result<Self, EngineError> {
5561        let mut set = active_open_paths()
5562            .lock()
5563            .unwrap_or_else(|e| e.into_inner());
5564        if set.contains(lock_path) {
5565            return Err(EngineError::Unsupported(format!(
5566                "database is locked by an in-flight task in this process: {} \
5567                 (a sibling `Database::open_path` / `AsyncDatabase::open_path` is \
5568                 still holding the lock; wait for it to complete, or shut down the \
5569                 prior caller before retrying)",
5570                lock_path.display()
5571            )));
5572        }
5573        set.insert(lock_path.to_path_buf());
5574        Ok(Self {
5575            path: lock_path.to_path_buf(),
5576        })
5577    }
5578}
5579
5580impl Drop for LockRegistryGuard {
5581    fn drop(&mut self) {
5582        let mut set = active_open_paths()
5583            .lock()
5584            .unwrap_or_else(|e| e.into_inner());
5585        set.remove(&self.path);
5586    }
5587}
5588
5589/// v7.37.5 — diagnostic predicate used by tests + future cross-
5590/// boundary force_unlock plumbing (Ask 2) to decide whether a
5591/// same-process retry should refuse honestly vs. reclaim.
5592#[doc(hidden)]
5593pub fn is_lock_path_active_in_process(lock_path: &Path) -> bool {
5594    active_open_paths()
5595        .lock()
5596        .map(|s| s.contains(lock_path))
5597        .unwrap_or(false)
5598}
5599
5600fn acquire_path_lock(lock_path: &Path) -> Result<(), EngineError> {
5601    // v7.37.5 (Ask 1) — the in-process registry check happens in
5602    // `LockRegistryGuard::try_acquire`, called by `open_path`
5603    // BEFORE this function. By the time we get here, the caller
5604    // already owns the registry slot; the on-disk acquire below
5605    // can race with same-pid siblings only when force_unlock
5606    // cleared the registry mid-flight (the operator's
5607    // single-instance contract), which is correct behaviour.
5608    for attempt in 0..2 {
5609        match std::fs::create_dir(lock_path) {
5610            Ok(()) => {
5611                // Best-effort owner record; liveness probing treats a
5612                // missing pid file as stale (crash between mkdir and
5613                // write is indistinguishable from an ancient lock).
5614                // v7.27 — lines 2+3 record the owner's environment
5615                // identity (hostname, boot id) so a prober in a
5616                // different namespace refuses instead of misreading
5617                // the pid. v7.34 — line 4 records the owner's process
5618                // start-time so a reused pid (container pid-1 restart)
5619                // is distinguishable from a live holder.
5620                let (host, boot) = host_identity();
5621                let start = process_start_time(std::process::id()).unwrap_or_default();
5622                let _ = std::fs::write(
5623                    lock_path.join("pid"),
5624                    format!("{}\n{host}\n{boot}\n{start}\n", std::process::id()),
5625                );
5626                return Ok(());
5627            }
5628            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempt == 0 => {
5629                let record = std::fs::read_to_string(lock_path.join("pid")).unwrap_or_default();
5630                let mut lines = record.lines();
5631                let owner = lines.next().and_then(|s| s.trim().parse::<u32>().ok());
5632                let lock_host = lines.next().unwrap_or("").trim().to_string();
5633                let lock_boot = lines.next().unwrap_or("").trim().to_string();
5634                let lock_start = lines.next().unwrap_or("").trim().to_string();
5635                // Note(v7.37.10 design choice): we do NOT auto-reclaim a
5636                // lock whose (pid, start-time) matches OUR own process.
5637                // Tempting fix for the mailrs 2026-06-19 recurrence —
5638                // "the prior open_path future got cancelled, its lock
5639                // leaked" — but `AsyncDatabase::open_path` runs the
5640                // blocking `Database::open_path` inside
5641                // `tokio::task::spawn_blocking`, which CANNOT be
5642                // cancelled mid-flight. When the awaiting future is
5643                // dropped (pool acquire-timeout), the spawn_blocking
5644                // task keeps running and STILL HOLDS the lock; auto-
5645                // reclaiming would let a concurrent retry steal a live
5646                // task's lock and corrupt WAL replay. The mailrs flow
5647                // is correctly resolved by waiting for the in-flight
5648                // replay to finish — sentori's spg-sqlx pool config
5649                // needs a higher `acquire_timeout` than spg's worst-
5650                // case replay time. Tracking that separately as a
5651                // spg-sqlx pool-default change for v7.38.
5652                // v7.27 — identity check BEFORE the pid probe. A pid
5653                // recorded in another namespace is undecidable both
5654                // ways (a stale lock can look held, a held lock can
5655                // look stale — the unsafe direction). Old-format
5656                // locks (pid only) keep the legacy same-host
5657                // assumption.
5658                // v7.37.10 — skip host_identity when the recorded owner
5659                // is PID 1. PID 1 means containerised; `docker compose
5660                // up -d` recreates the container with a new hostname so
5661                // a strict host-identity match would refuse every
5662                // restart even when the start-time check below would
5663                // correctly declare the old generation stale. The
5664                // start-time check is more accurate for the container
5665                // case anyway — let it decide.
5666                let lock_is_pid1 = owner == Some(1);
5667                if !lock_host.is_empty() && !lock_is_pid1 {
5668                    let (my_host, my_boot) = host_identity();
5669                    let same_env = lock_host == my_host
5670                        && (lock_boot.is_empty() || my_boot.is_empty() || lock_boot == my_boot);
5671                    if !same_env {
5672                        return Err(EngineError::Unsupported(format!(
5673                            "database lock {} was taken in a different host/container \
5674                             (owner: pid {} on {:?}; we are {:?}) — liveness is \
5675                             undecidable from here. If you are sure the owner is gone, \
5676                             call Database::force_unlock() or `spg import --force-unlock`.",
5677                            lock_path.display(),
5678                            owner.unwrap_or(0),
5679                            lock_host,
5680                            my_host
5681                        )));
5682                    }
5683                }
5684                // v7.34 (crash-recovery P0 #2) — pid-reuse-safe liveness.
5685                // A bare `pid_alive` self-deadlocks in a container: the
5686                // dead owner was pid 1, `docker start` reuses the container
5687                // so the prober is pid 1 too, and `ps -p 1` always succeeds.
5688                // The recorded (pid, start-time) pair settles it — the
5689                // owner is alive ONLY if its pid is alive AND its CURRENT
5690                // start-time still matches the recorded one:
5691                //  - container restart: pid 1 alive, but the new pid-1's
5692                //    start-time differs from the dead owner's → stale.
5693                //  - genuine double-open (same live process): start-time
5694                //    matches (it wrote it) → held — correctly refused, so a
5695                //    second writer can't steal a live lock.
5696                // v7.37.10 — for PID-1 owners with no recorded start-time
5697                // (a pre-v7.34 lock from a previous container generation),
5698                // treat as stale: a new container's PID 1 cannot share
5699                // identity with the previous container's PID 1. Gated on
5700                // `process_start_time` having returned `Some(_)` so the
5701                // arm only fires on Linux (where /proc is queryable); on
5702                // macOS, where PID 1 is `launchd` (a real long-running
5703                // system process), the empty-start-time fallback keeps
5704                // the safer pid-alive answer.
5705                let owner_alive = owner.is_some_and(|p| {
5706                    if !pid_alive(p) {
5707                        return false;
5708                    }
5709                    let now = process_start_time(p);
5710                    match (now, lock_start.is_empty()) {
5711                        (Some(t), false) => t == lock_start,
5712                        (Some(_), true) if p == 1 => false,
5713                        _ => true,
5714                    }
5715                });
5716                if owner_alive {
5717                    return Err(EngineError::Unsupported(format!(
5718                        "database is locked by another process (pid {}): {}; \
5719                         stop that process first, or call Database::force_unlock()",
5720                        owner.unwrap_or(0),
5721                        lock_path.display()
5722                    )));
5723                }
5724                // Stale — owner pid dead, reused, or unrecorded. Reclaim.
5725                eprintln!(
5726                    "spg-embedded: reclaiming stale lock {} (owner pid {:?} not a live holder)",
5727                    lock_path.display(),
5728                    owner
5729                );
5730                std::fs::remove_dir_all(lock_path).map_err(io_err)?;
5731                // Loop retries the create_dir; a concurrent reclaimer
5732                // winning the race surfaces as AlreadyExists on
5733                // attempt 1 below.
5734            }
5735            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
5736                return Err(EngineError::Unsupported(format!(
5737                    "database is locked by another process: {}; \
5738                     stop that process first, or call Database::force_unlock()",
5739                    lock_path.display()
5740                )));
5741            }
5742            Err(e) => return Err(io_err(e)),
5743        }
5744    }
5745    unreachable!("acquire_path_lock loop covers both attempts")
5746}
5747
5748/// Probe whether `pid` is a live process. Unix: `ps -p` via the
5749/// system binary (std-only — no libc dependency). `ps -p` exits 0
5750/// for ANY live pid regardless of owner; `kill -0` was rejected
5751/// here because it fails with EPERM on another user's live process,
5752/// which would read as "dead" and reclaim a held lock. Probe
5753/// failure (no `ps` binary, exec error) conservatively reports
5754/// alive so locks are never auto-reclaimed on doubt; non-unix
5755/// targets do the same.
5756#[cfg(unix)]
5757fn pid_alive(pid: u32) -> bool {
5758    // v7.37.10 — `/proc/<pid>` directory existence is the
5759    // most reliable liveness signal on Linux, and crucially
5760    // doesn't depend on `procps` being installed in the
5761    // container image. Minimal images(rust:slim, distroless,
5762    // mailrs-mmalloc's stripped runtime)don't ship `ps`, and
5763    // a failed `Command::spawn` previously fell back to
5764    // "treat as alive" — which inverted the meaning of every
5765    // stale-lock probe in those environments. Probe /proc
5766    // first on Linux; fall back to `ps -p` on other unix
5767    // (macOS / BSD), where procps-equivalent tools ship by
5768    // default.
5769    #[cfg(target_os = "linux")]
5770    {
5771        if std::path::Path::new("/proc").is_dir() {
5772            return std::path::Path::new(&format!("/proc/{pid}")).exists();
5773        }
5774    }
5775    match std::process::Command::new("ps")
5776        .arg("-p")
5777        .arg(pid.to_string())
5778        .stdout(std::process::Stdio::null())
5779        .stderr(std::process::Stdio::null())
5780        .status()
5781    {
5782        Ok(status) => status.success(),
5783        Err(_) => true,
5784    }
5785}
5786
5787#[cfg(not(unix))]
5788fn pid_alive(_pid: u32) -> bool {
5789    true
5790}
5791
5792/// Strip leading whitespace, `--` line comments and NON-conditional
5793/// block comments from a chunk so statement-head checks (COPY
5794/// detection most notably) see the first real token. pg_dump
5795/// prefixes every data block with a `-- Data for Name: …;` banner —
5796/// which itself contains semicolons, so head checks must run on the
5797/// stripped text. MySQL executable conditional comments (`/*!`) are
5798/// content and stay.
5799/// v7.22 — see `split_statements`' `mysql_escapes` tracking. Only
5800/// short chunks are inspected (the signal statements are one-liners;
5801/// COPY data blocks are skipped by the length guard).
5802fn note_dialect_signals(chunk: &str, mysql_escapes: &mut bool) {
5803    if chunk.len() > 4096 {
5804        return;
5805    }
5806    let lower = chunk.to_ascii_lowercase();
5807    if lower.contains("sql_mode") {
5808        *mysql_escapes = true;
5809    } else if lower.contains("standard_conforming_strings") {
5810        *mysql_escapes = lower.contains("off");
5811    }
5812}
5813
5814fn strip_leading_sql_noise(mut s: &str) -> &str {
5815    loop {
5816        let t = s.trim_start();
5817        if let Some(rest) = t.strip_prefix("--") {
5818            s = rest.split_once('\n').map_or("", |(_, r)| r);
5819            continue;
5820        }
5821        if t.starts_with("/*") && !t.starts_with("/*!") {
5822            match t.find("*/") {
5823                Some(e) => {
5824                    s = &t[e + 2..];
5825                    continue;
5826                }
5827                None => return "",
5828            }
5829        }
5830        return t;
5831    }
5832}
5833
5834/// Split a multi-statement SQL script into individual statements on
5835/// top-level `;`, honouring single-quoted strings (with `''`
5836/// escapes), double-quoted identifiers, dollar-quoted bodies
5837/// (`$tag$ … $tag$`), line comments (`--`) and MySQL executable
5838/// conditional comments (`/*!… */` stay statement content; plain
5839/// nested block comments don't). Chunks that contain no statement
5840/// content (whitespace / comments only) are dropped. PG's
5841/// simple-query protocol does this server-side; the embed path owns
5842/// it here.
5843///
5844/// v7.22 (mailrs round-13 gap 1) — psql meta-command lines are
5845/// dropped for client parity: a line whose first non-whitespace
5846/// byte is `\` BETWEEN statements (PG 18's pg_dump wraps scripts in
5847/// `\restrict` / `\unrestrict`) never reaches the parser, the same
5848/// way psql consumes `\`-lines client-side and never sends them. A
5849/// mid-statement backslash stays an ordinary byte — pg_dump only
5850/// emits meta-commands between statements.
5851pub fn split_statements(sql: &str) -> Vec<&str> {
5852    let bytes = sql.as_bytes();
5853    let mut stmts = Vec::new();
5854    let mut start = 0usize;
5855    let mut has_content = false;
5856    // v7.22 (round-13 T3) — stream-tracked string dialect, mirroring
5857    // the engine's session flag: a statement mentioning `sql_mode`
5858    // (mysqldump preamble, often inside `/*!…*/`) switches plain
5859    // strings to backslash-escape scanning;
5860    // `standard_conforming_strings` (pg_dump preamble) switches
5861    // back. Without this the scanner ends a MySQL `'…\'…'` literal
5862    // early and splits inside data.
5863    let mut mysql_escapes = false;
5864    let mut i = 0usize;
5865    while i < bytes.len() {
5866        match bytes[i] {
5867            b'\\' if !has_content => {
5868                // Start-of-statement `\` = psql meta-command line.
5869                // Consume through end-of-line; restart the chunk
5870                // after it so the line never lands in the output.
5871                while i < bytes.len() && bytes[i] != b'\n' {
5872                    i += 1;
5873                }
5874                start = if i < bytes.len() { i + 1 } else { i };
5875            }
5876            b'\'' => {
5877                has_content = true;
5878                // PG escape-string form `E'...'` honours backslash
5879                // escapes (`E'a\';b'` is ONE literal) — detect via
5880                // the immediately-preceding standalone E/e. MySQL
5881                // dialect sessions treat EVERY plain string that way.
5882                let escape_string = mysql_escapes
5883                    || (i >= 1
5884                        && matches!(bytes[i - 1], b'e' | b'E')
5885                        && !(i >= 2
5886                            && (bytes[i - 2].is_ascii_alphanumeric() || bytes[i - 2] == b'_')));
5887                i += 1;
5888                while i < bytes.len() {
5889                    if escape_string && bytes[i] == b'\\' {
5890                        // Skip the escaped byte (covers \' and \\).
5891                        i += 2;
5892                        continue;
5893                    }
5894                    if bytes[i] == b'\'' {
5895                        // `''` is an escaped quote inside the literal.
5896                        if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
5897                            i += 2;
5898                            continue;
5899                        }
5900                        break;
5901                    }
5902                    i += 1;
5903                }
5904            }
5905            b'"' => {
5906                has_content = true;
5907                i += 1;
5908                while i < bytes.len() && bytes[i] != b'"' {
5909                    i += 1;
5910                }
5911            }
5912            b'$' => {
5913                // Possible dollar-quote opener `$tag$` (tag may be
5914                // empty). If the shape doesn't match, it's a plain
5915                // `$` (positional param) — fall through.
5916                let tag_end = bytes[i + 1..]
5917                    .iter()
5918                    .position(|&b| !(b.is_ascii_alphanumeric() || b == b'_'))
5919                    .map(|off| i + 1 + off);
5920                if let Some(te) = tag_end
5921                    && te < bytes.len()
5922                    && bytes[te] == b'$'
5923                {
5924                    has_content = true;
5925                    let tag = &sql[i..=te];
5926                    // Find the closing `$tag$`.
5927                    if let Some(close) = sql[te + 1..].find(tag) {
5928                        i = te + 1 + close + tag.len();
5929                        continue;
5930                    }
5931                    // Unterminated — consume the rest; the parser
5932                    // will report it.
5933                    i = bytes.len();
5934                    continue;
5935                }
5936                has_content = true;
5937            }
5938            b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
5939                while i < bytes.len() && bytes[i] != b'\n' {
5940                    i += 1;
5941                }
5942            }
5943            b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
5944                // v7.22 (round-13 T3) — MySQL conditional comments
5945                // `/*!40101 … */` are EXECUTABLE (mysqldump wraps
5946                // its whole preamble + DISABLE KEYS hints in them);
5947                // they must stay statement content for the engine,
5948                // not be skipped as commentary.
5949                if i + 2 < bytes.len() && bytes[i + 2] == b'!' {
5950                    has_content = true;
5951                }
5952                let mut depth = 1usize;
5953                i += 2;
5954                while i < bytes.len() && depth > 0 {
5955                    if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
5956                        depth += 1;
5957                        i += 2;
5958                    } else if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
5959                        depth -= 1;
5960                        i += 2;
5961                    } else {
5962                        i += 1;
5963                    }
5964                }
5965                continue;
5966            }
5967            b';' => {
5968                if has_content {
5969                    let head = &sql[start..i];
5970                    // v7.22 (round-13 T2) — a `COPY … FROM stdin;`
5971                    // statement owns its following data block
5972                    // through the `\.` terminator line (data lines
5973                    // may contain `;`, so generic splitting would
5974                    // shred them). Swallow head + data into ONE
5975                    // chunk; `execute_script` lowers it to INSERTs.
5976                    // pg_dump prefixes the COPY with a comment
5977                    // banner — strip it before the head check.
5978                    let head_clean = strip_leading_sql_noise(head);
5979                    let is_copy_head = head_clean
5980                        .get(..4)
5981                        .is_some_and(|p| p.eq_ignore_ascii_case("copy"))
5982                        && spg_engine::copy::parse_copy_from_stdin_head(head_clean).is_some();
5983                    if is_copy_head {
5984                        // Scan whole lines after the ';' until the
5985                        // `\.` terminator (or EOF — torn dumps lose
5986                        // their tail, same as psql would error).
5987                        let mut j = i + 1;
5988                        let data_end;
5989                        loop {
5990                            if j >= bytes.len() {
5991                                data_end = bytes.len();
5992                                break;
5993                            }
5994                            let line_end = sql[j..].find('\n').map_or(bytes.len(), |off| j + off);
5995                            if sql[j..line_end].trim_end_matches('\r').trim() == "\\." {
5996                                data_end = j;
5997                                i = line_end; // bottom i += 1 skips \n
5998                                break;
5999                            }
6000                            j = line_end + 1;
6001                        }
6002                        stmts.push(&sql[start..data_end]);
6003                        if data_end == bytes.len() {
6004                            i = bytes.len();
6005                        }
6006                        start = i + 1;
6007                        has_content = false;
6008                        i += 1;
6009                        continue;
6010                    }
6011                    note_dialect_signals(head, &mut mysql_escapes);
6012                    stmts.push(head);
6013                }
6014                start = i + 1;
6015                has_content = false;
6016            }
6017            b => {
6018                if !b.is_ascii_whitespace() {
6019                    has_content = true;
6020                }
6021            }
6022        }
6023        i += 1;
6024    }
6025    if has_content {
6026        stmts.push(&sql[start..]);
6027    }
6028    stmts
6029}
6030
6031/// v7.39 (parallel-agg P0) — std-side ParallelRunner: scoped threads,
6032/// one per shard. Shard counts are small (<= 8) and gated to scans of
6033/// 100k+ rows, so per-query spawn cost (~10-20 us/thread) is noise
6034/// next to the scan itself; a pooled runner is a P3 refinement.
6035struct ScopedThreadRunner;
6036
6037impl spg_engine::ParallelRunner for ScopedThreadRunner {
6038    fn run_shards(
6039        &self,
6040        n: usize,
6041        f: &(dyn Fn(usize) -> Box<dyn core::any::Any + Send> + Sync),
6042    ) -> Vec<Box<dyn core::any::Any + Send>> {
6043        std::thread::scope(|s| {
6044            let handles: Vec<_> = (0..n).map(|i| s.spawn(move || f(i))).collect();
6045            handles
6046                .into_iter()
6047                .map(|h| h.join().expect("shard panicked"))
6048                .collect()
6049        })
6050    }
6051}
6052
6053#[cfg(test)]
6054mod tests {
6055    use super::*;
6056
6057    #[test]
6058    fn split_statements_basic_and_trailing() {
6059        assert_eq!(
6060            split_statements("CREATE TABLE a (x INT); INSERT INTO a VALUES (1)"),
6061            vec!["CREATE TABLE a (x INT)", " INSERT INTO a VALUES (1)"]
6062        );
6063        // whitespace/comment-only chunks drop
6064        assert!(split_statements("  ;; -- nothing\n;").is_empty());
6065    }
6066
6067    #[test]
6068    fn split_statements_quoting_forms() {
6069        // ';' inside a plain literal, a doubled quote, an E-string
6070        // backslash escape, a quoted identifier, and a dollar-quoted
6071        // body must not split.
6072        let cases = [
6073            "INSERT INTO t VALUES ('a;b')",
6074            "INSERT INTO t VALUES ('it''s; fine')",
6075            r"INSERT INTO t VALUES (E'it\'s; fine')",
6076            "CREATE TABLE \"odd;name\" (x INT)",
6077            "DO $body$ BEGIN PERFORM 1; END $body$",
6078            "DO $$ SELECT 1; $$",
6079        ];
6080        for sql in cases {
6081            assert_eq!(split_statements(sql), vec![sql], "must stay whole: {sql}");
6082        }
6083        // ...and each still splits cleanly from a neighbour.
6084        for sql in cases {
6085            let script = format!("{sql};\nSELECT 2");
6086            assert_eq!(
6087                split_statements(&script),
6088                vec![sql, "\nSELECT 2"],
6089                "must split after: {sql}"
6090            );
6091        }
6092    }
6093
6094    #[test]
6095    fn split_statements_drops_psql_meta_lines() {
6096        // v7.22 round-13 gap 1 — PG 18 pg_dump wraps scripts in
6097        // `\restrict` / `\unrestrict`; psql parity = the lines never
6098        // reach the parser.
6099        let script = "\\restrict TOKEN123\nSELECT 1;\n\\unrestrict TOKEN123\nSELECT 2;\n\\.\n";
6100        assert_eq!(split_statements(script), vec!["SELECT 1", "SELECT 2"]);
6101        // Mid-statement backslash is NOT a meta-command.
6102        let s2 = r"SELECT E'a\\b'";
6103        assert_eq!(split_statements(s2), vec![s2]);
6104    }
6105
6106    #[test]
6107    fn split_statements_comments_hide_semicolons() {
6108        let script = "-- c1 ; still comment\nSELECT 1; /* a ; b /* nested ; */ */ SELECT 2";
6109        let got = split_statements(script);
6110        assert_eq!(got.len(), 2);
6111        assert!(got[0].contains("SELECT 1"));
6112        assert!(got[1].contains("SELECT 2"));
6113    }
6114
6115    #[test]
6116    fn in_memory_create_insert_select() {
6117        let mut db = Database::open_in_memory();
6118        db.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
6119            .unwrap();
6120        db.execute("INSERT INTO t VALUES (1, 'alice')").unwrap();
6121        db.execute("INSERT INTO t VALUES (2, 'bob')").unwrap();
6122        let rows = db.query("SELECT id FROM t WHERE id = 1").unwrap();
6123        assert_eq!(rows.len(), 1);
6124        match &rows[0][0] {
6125            Value::Int(1) => {}
6126            other => panic!("expected Int(1), got {other:?}"),
6127        }
6128    }
6129
6130    #[test]
6131    fn query_on_non_select_errors() {
6132        let mut db = Database::open_in_memory();
6133        db.execute("CREATE TABLE t (id INT)").unwrap();
6134        let r = db.query("INSERT INTO t VALUES (1)");
6135        assert!(r.is_err(), "query() on INSERT must error");
6136    }
6137
6138    #[test]
6139    fn snapshot_roundtrip() {
6140        let mut db = Database::open_in_memory();
6141        db.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
6142        db.execute("INSERT INTO t VALUES (42)").unwrap();
6143        let bytes = db.snapshot();
6144        let mut restored = Database::restore(&bytes).unwrap();
6145        let rows = restored.query("SELECT id FROM t WHERE id = 42").unwrap();
6146        assert_eq!(rows.len(), 1);
6147        match &rows[0][0] {
6148            Value::Int(42) => {}
6149            other => panic!("expected Int(42), got {other:?}"),
6150        }
6151    }
6152
6153    #[test]
6154    fn from_spg_row_trait_shape() {
6155        struct User {
6156            _id: i32,
6157        }
6158        impl FromSpgRow for User {
6159            fn from_spg_row(row: &[Value]) -> Result<Self, EngineError> {
6160                match row.first() {
6161                    Some(Value::Int(n)) => Ok(Self { _id: *n }),
6162                    _ => Err(EngineError::Unsupported("bad id".into())),
6163                }
6164            }
6165        }
6166        let row = vec![Value::Int(7)];
6167        let _u = User::from_spg_row(&row).unwrap();
6168    }
6169
6170    // ─────────────────────────────────────────────────────────────
6171    // v7.37.5 — mailrs crash-recovery lock-hang regression tests.
6172    // Three asks; each closed atomically:
6173    //   Ask 1 — in-process registry refuses sibling sl-blocking
6174    //   Ask 2 — force_unlock clears the in-process registry too
6175    //   Ask 3 — apply_redo batches DELETE/INSERT/UPDATE so the
6176    //           index rebuild happens once per replay, not once
6177    //           per WAL record
6178    // ─────────────────────────────────────────────────────────────
6179
6180    /// v7.39 (round 262) — 14 tests in this module run in PARALLEL and
6181    /// six of them remove their directory at the end, so the name has to
6182    /// be unique per CALL, not merely per instant: `SystemTime::now()`
6183    /// is not nanosecond-distinct on macOS, two tests could land on the
6184    /// same directory, and one's cleanup deleted the other's database
6185    /// mid-run (seen as `seed checkpoint: … No such file or directory`).
6186    /// The repo's server e2e helpers already add an atomic serial for
6187    /// exactly this; round 258 fixed the same shape in the round-249
6188    /// test file.
6189    fn tmpdir() -> std::path::PathBuf {
6190        use core::sync::atomic::{AtomicU32, Ordering};
6191        static SEQ: AtomicU32 = AtomicU32::new(0);
6192        let base = std::env::temp_dir().join(format!(
6193            "spg-v7375-lockhang-{}-{}-{}",
6194            std::process::id(),
6195            std::time::SystemTime::now()
6196                .duration_since(std::time::UNIX_EPOCH)
6197                .unwrap()
6198                .as_nanos(),
6199            SEQ.fetch_add(1, Ordering::Relaxed)
6200        ));
6201        std::fs::create_dir_all(&base).unwrap();
6202        base
6203    }
6204
6205    /// v7.37.13 — directly tests the v7.37.10 time-based
6206    /// auto-checkpoint claim that mailrs's 2026-06-24 prod report
6207    /// proved was UNVERIFIED before shipping.
6208    ///
6209    /// Setup: open a path, set a short time threshold (200 ms),
6210    /// write rows over a 600 ms window, verify base.spg mtime
6211    /// advanced AT LEAST ONCE — i.e. the time path actually fired
6212    /// trigger_checkpoint AND the worker successfully wrote a new
6213    /// snapshot.
6214    ///
6215    /// This test would have caught my v7.37.10 ship-with-no-verify
6216    /// failure mode if it existed at that time. Adding it now as
6217    /// part of v7.37.13's honest-fix-the-fix work.
6218    #[test]
6219    fn v7_37_10_time_based_checkpoint_actually_fires() {
6220        let dir = tmpdir();
6221        let db_path = dir.join("ckpt.spg");
6222        let mut db = Database::open_path(&db_path).expect("open");
6223        db.execute("CREATE TABLE t (id BIGINT)").expect("ddl");
6224        // Force initial checkpoint so base.spg exists with a known
6225        // mtime to compare against.
6226        db.checkpoint().expect("seed checkpoint");
6227        let baseline_mtime = std::fs::metadata(&db_path)
6228            .expect("base mtime")
6229            .modified()
6230            .expect("modified");
6231
6232        // Tighten the time threshold to 200 ms so the test runs
6233        // fast. Default 60 s would make this test multi-minute.
6234        db.set_checkpoint_time_threshold(Some(core::time::Duration::from_millis(200)));
6235
6236        // Wait > threshold so the next write trips the trigger.
6237        std::thread::sleep(core::time::Duration::from_millis(250));
6238
6239        // ONE write after the time threshold elapses — the trigger
6240        // should fire on this call.
6241        db.execute("INSERT INTO t VALUES (1)").expect("insert");
6242
6243        // Drain the async worker so the snapshot lands before we
6244        // check mtime.
6245        db.checkpoint_wait().expect("wait async checkpoint");
6246
6247        let new_mtime = std::fs::metadata(&db_path)
6248            .expect("base mtime after")
6249            .modified()
6250            .expect("modified after");
6251        assert!(
6252            new_mtime > baseline_mtime,
6253            "base.spg mtime did NOT advance after time-trigger window + write \
6254             (baseline {baseline_mtime:?}, after {new_mtime:?}); this is the \
6255             exact failure mailrs observed in prod 2026-06-24"
6256        );
6257    }
6258
6259    /// Companion: with the timer DISABLED
6260    /// (`SPG_EMBEDDED_CHECKPOINT_SECONDS=0` semantically), writes
6261    /// alone do NOT advance the base mtime — only the byte-threshold
6262    /// path does (and that's not exercised here). Verifies the
6263    /// disable knob actually disables.
6264    #[test]
6265    fn time_based_checkpoint_can_be_disabled() {
6266        let dir = tmpdir();
6267        let db_path = dir.join("ckpt.spg");
6268        let mut db = Database::open_path(&db_path).expect("open");
6269        db.execute("CREATE TABLE t (id BIGINT)").expect("ddl");
6270        db.checkpoint().expect("seed checkpoint");
6271        let baseline_mtime = std::fs::metadata(&db_path)
6272            .expect("base mtime")
6273            .modified()
6274            .expect("modified");
6275
6276        db.set_checkpoint_time_threshold(None);
6277        // Tighten bytes to a huge value too, so neither path fires.
6278        db.set_checkpoint_threshold_bytes(u64::MAX);
6279
6280        std::thread::sleep(core::time::Duration::from_millis(250));
6281        db.execute("INSERT INTO t VALUES (1)").expect("insert");
6282        db.checkpoint_wait().expect("wait async");
6283
6284        let new_mtime = std::fs::metadata(&db_path)
6285            .expect("base mtime after")
6286            .modified()
6287            .expect("modified after");
6288        assert_eq!(
6289            new_mtime, baseline_mtime,
6290            "with time threshold disabled + bytes effectively-disabled, base.spg \
6291             mtime should NOT advance on writes"
6292        );
6293    }
6294
6295    /// v7.37 Epic Du — a bare `CHECKPOINT` SQL statement forces an
6296    /// immediate, synchronous checkpoint (durability barrier),
6297    /// matching PG where CHECKPOINT flushes now instead of waiting
6298    /// for the auto (byte / time) trigger. Both auto-triggers are
6299    /// disabled here so the ONLY thing that can advance
6300    /// `checkpoint_stats().total_count` is the explicit statement —
6301    /// and the base snapshot's mtime must move too (real flush).
6302    #[test]
6303    fn bare_checkpoint_forces_real_checkpoint() {
6304        let dir = tmpdir();
6305        let db_path = dir.join("ckpt.spg");
6306        let mut db = Database::open_path(&db_path).expect("open");
6307
6308        // Disable both auto-checkpoint triggers so nothing but the
6309        // explicit CHECKPOINT can run a checkpoint.
6310        db.set_checkpoint_time_threshold(None);
6311        db.set_checkpoint_threshold_bytes(u64::MAX);
6312
6313        db.execute("CREATE TABLE t (id BIGINT)").expect("ddl");
6314        db.execute("INSERT INTO t VALUES (1)").expect("insert");
6315
6316        let before_count = db.checkpoint_stats().total_count;
6317        // The base snapshot only materialises once a checkpoint runs;
6318        // on a fresh open_path it may not exist yet.
6319        let before_mtime = std::fs::metadata(&db_path)
6320            .ok()
6321            .and_then(|m| m.modified().ok());
6322        // mtime resolution can be coarse; make sure any advance is
6323        // observable.
6324        std::thread::sleep(core::time::Duration::from_millis(10));
6325
6326        // The wired statement under test.
6327        let res = db.execute("CHECKPOINT").expect("checkpoint stmt");
6328        assert!(
6329            matches!(res, QueryResult::CommandOk { .. }),
6330            "CHECKPOINT should return CommandOk, got {res:?}"
6331        );
6332
6333        // total_count advanced → a real checkpoint ran (synchronous,
6334        // so it has already completed by the time execute returned).
6335        let after_count = db.checkpoint_stats().total_count;
6336        assert!(
6337            after_count > before_count,
6338            "bare CHECKPOINT did not run a real checkpoint: \
6339             total_count {before_count} -> {after_count}"
6340        );
6341
6342        // …and it was a real flush: the on-disk base snapshot exists
6343        // and (if it already existed) its mtime moved forward.
6344        let after_mtime = std::fs::metadata(&db_path)
6345            .expect("base.spg must exist after CHECKPOINT flush")
6346            .modified()
6347            .expect("modified after");
6348        if let Some(before_mtime) = before_mtime {
6349            assert!(
6350                after_mtime > before_mtime,
6351                "bare CHECKPOINT did not flush base.spg (mtime {before_mtime:?} -> {after_mtime:?})"
6352            );
6353        }
6354    }
6355
6356    /// v7.37.14 (A2.2 TDD [PG+]) — `Database::execute` increments
6357    /// the foreground active-query counter for its duration so
6358    /// the background freezer / flusher can back off when user
6359    /// queries are in flight. The counter is decremented on the
6360    /// way out via an RAII guard (panic-safe — the test verifies
6361    /// the counter returns to 0 after both Ok and Err paths).
6362    #[test]
6363    fn v7_37_14_active_query_count_bumps_during_execute() {
6364        let mut db = Database::open_in_memory();
6365        let counter = db.active_query_count_handle();
6366        assert_eq!(
6367            counter.load(std::sync::atomic::Ordering::Acquire),
6368            0,
6369            "idle db has 0 active queries"
6370        );
6371        db.execute("CREATE TABLE t (id INT)").expect("ddl");
6372        // After execute, counter must return to 0 (RAII guard
6373        // decremented).
6374        assert_eq!(
6375            counter.load(std::sync::atomic::Ordering::Acquire),
6376            0,
6377            "active_query_count must return to 0 after Ok-path execute"
6378        );
6379
6380        // Err path: malformed SQL surfaces an Err. Counter must
6381        // STILL return to 0 (the guard's Drop runs on the unwind
6382        // / early-return).
6383        let _ = db.execute("CREATE BANANA");
6384        assert_eq!(
6385            counter.load(std::sync::atomic::Ordering::Acquire),
6386            0,
6387            "active_query_count must return to 0 even after Err-path execute"
6388        );
6389    }
6390
6391    /// v7.37.14 (A2.2 TDD [PG+]) — the freezer-loop adaptive
6392    /// yield reads the counter handle without acquiring db.lock().
6393    /// Verifies the shared-Arc pattern: a background reader sees
6394    /// the bumped value while a foreground writer is mid-execute.
6395    #[test]
6396    fn v7_37_14_active_query_count_visible_from_arc_clone() {
6397        let db = Database::open_in_memory();
6398        let counter_handle = db.active_query_count_handle();
6399        // Both references see the same atomic.
6400        assert_eq!(
6401            Arc::strong_count(&counter_handle),
6402            2,
6403            "Database + this test each own an Arc clone"
6404        );
6405        // Simulate the freezer's read pattern: bump from one
6406        // reference, observe from the other.
6407        db.active_query_count
6408            .fetch_add(3, std::sync::atomic::Ordering::AcqRel);
6409        assert_eq!(
6410            counter_handle.load(std::sync::atomic::Ordering::Acquire),
6411            3,
6412            "freezer's Arc-cloned handle sees the same value"
6413        );
6414        db.active_query_count
6415            .store(0, std::sync::atomic::Ordering::Release);
6416    }
6417
6418    /// v7.37.14 (A2.5-stub TDD) — the parser silently absorbs
6419    /// `SELECT ... FOR UPDATE` (and FOR SHARE / FOR KEY SHARE /
6420    /// FOR NO KEY UPDATE) so existing client code paths
6421    /// (mailrs / Rails / Django) keep loading. Pre-v7.37.14 there
6422    /// was no way to surface "your workload widely uses FOR UPDATE
6423    /// but it's currently a no-op"; the counter
6424    /// `spg_engine::silent_for_update_count()` is the observability
6425    /// hook so operators can gauge how much of the workload
6426    /// depends on the advisory locks before v7.37.15 lands the
6427    /// per-row tuple locking that actually honours them.
6428    ///
6429    /// Test: parse 4 clauses (FOR UPDATE + FOR SHARE OF + FOR KEY
6430    /// SHARE + FOR NO KEY UPDATE), assert counter delta = 4.
6431    #[test]
6432    fn v7_37_14_silent_for_update_clauses_bump_counter() {
6433        let dir = tmpdir();
6434        let db_path = dir.join("for_update_telemetry_db");
6435        let mut db = Database::open_path(&db_path).expect("open");
6436        db.execute("CREATE TABLE t (id BIGINT, name TEXT)")
6437            .expect("ddl");
6438        db.execute("INSERT INTO t VALUES (1, 'a'), (2, 'b')")
6439            .expect("seed");
6440
6441        let baseline = spg_engine::silent_for_update_count();
6442
6443        // Each statement contains exactly one FOR clause; the
6444        // parser-side consume loop bumps the counter once per
6445        // clause. Stack-clause statements (e.g. `FOR UPDATE OF a
6446        // FOR SHARE OF b`) would bump twice.
6447        db.execute("SELECT * FROM t FOR UPDATE").expect("fu");
6448        db.execute("SELECT * FROM t FOR SHARE OF t").expect("fs");
6449        db.execute("SELECT * FROM t FOR KEY SHARE").expect("fks");
6450        db.execute("SELECT * FROM t FOR NO KEY UPDATE")
6451            .expect("fnku");
6452
6453        let after = spg_engine::silent_for_update_count();
6454        assert_eq!(
6455            after - baseline,
6456            4,
6457            "4 FOR-clause statements must increment the counter by 4 \
6458             (baseline {baseline}, after {after}); without this telemetry \
6459             a workload that depends on advisory FOR UPDATE has no signal \
6460             that the locks are not enforced pre-v7.37.15."
6461        );
6462    }
6463
6464    /// v7.37.13 (A1.6 TDD red-then-green) — `freeze_oldest_to_cold`
6465    /// performs a `tmp + rename` of the cold-segment file but must
6466    /// also fsync the **parent directory** so a power loss after the
6467    /// rename does not lose the directory entry that names the new
6468    /// segment. Without this, the seg file inode persists but the
6469    /// directory entry is gone on restart, and the catalog points at
6470    /// a path the kernel claims does not exist.
6471    ///
6472    /// This is `AUDIT-3-categories.md` A1.6 / Top-6 P0 #4 — a real
6473    /// data-loss path until v7.37.13 closes it. Matches PG's
6474    /// `durable_rename` posture (rename + fsync_dir).
6475    ///
6476    /// TDD invariant: the test reads [`FSYNC_DIR_CALL_COUNT`] before
6477    /// and after `freeze_oldest_to_cold`, asserts the delta is at
6478    /// least one (the call site this fix adds). A future regression
6479    /// that removes the `fsync_dir(...)` line will turn the delta
6480    /// back to zero and re-redden this test.
6481    #[test]
6482    fn v7_37_13_freeze_to_cold_fsyncs_cold_segments_dir() {
6483        let dir = tmpdir();
6484        // open_path derives `cold_segments_dir = {parent}/{stem}.spg/segments`
6485        // (see L2076-2084). Using a `.spg`-suffixed db_path collides
6486        // (`{stem}.spg` would be both the db file and the parent of
6487        // segments/, triggering ENOTDIR on mkdir). Pick a bare stem
6488        // so the segments tree lives at `<stem>.spg/segments` next to
6489        // the db file.
6490        let db_path = dir.join("freeze_fsync_db");
6491        let mut db = Database::open_path(&db_path).expect("open");
6492        db.execute("CREATE TABLE users (id BIGINT PRIMARY KEY, name TEXT)")
6493            .expect("ddl");
6494        db.execute("CREATE INDEX by_id ON users (id)").expect("ix");
6495        for i in 0..200i64 {
6496            db.execute(&format!("INSERT INTO users VALUES ({i}, 'u-{i}')"))
6497                .expect("insert");
6498        }
6499        // Seed a checkpoint so any FSYNC_DIR_CALL_COUNT bumps from
6500        // the open / checkpoint / WAL-chunk-rotation paths are
6501        // captured into `baseline` — the assertion measures only
6502        // the delta produced by `freeze_oldest_to_cold`.
6503        db.checkpoint().expect("seed checkpoint");
6504
6505        let baseline = FSYNC_DIR_CALL_COUNT.load(std::sync::atomic::Ordering::Relaxed);
6506        db.freeze_oldest_to_cold("users", "by_id", 100)
6507            .expect("freeze");
6508        let after = FSYNC_DIR_CALL_COUNT.load(std::sync::atomic::Ordering::Relaxed);
6509
6510        assert!(
6511            after > baseline,
6512            "freeze_oldest_to_cold must fsync the cold_segments_dir \
6513             after the tmp->final rename (baseline {baseline}, after \
6514             {after}); without this fsync, a crash between the rename \
6515             and the next checkpoint loses the directory entry naming \
6516             the seg file and the catalog points at a path that does \
6517             not exist on restart (AUDIT-3-categories A1.6 / Top-6 P0 #4)."
6518        );
6519    }
6520
6521    /// v7.37.13 (A1.4 / A1.5 TDD) — combined policy verification.
6522    ///
6523    /// Run as a SINGLE test because FSYNC_RETRY_OVERRIDE +
6524    /// FSYNC_FAIL_INJECT are process-wide statics; running two
6525    /// scenarios as separate `#[test]`s allows cargo's parallel
6526    /// runner to interleave them and stomp each other's overrides.
6527    ///
6528    /// Phase 1 (retry path / A1.5):
6529    ///   - Force FSYNC_RETRY_OVERRIDE = 1 (= SPG_DATA_SYNC_RETRY=on)
6530    ///   - Arm inject
6531    ///   - INSERT — expect graceful Err (legacy poison path), no panic
6532    ///
6533    /// Phase 2 (default abort path / A1.4):
6534    ///   - Force FSYNC_RETRY_OVERRIDE = 0 (= default)
6535    ///   - Disable async checkpoint paths so worker can't consume
6536    ///     inject in a background thread
6537    ///   - Arm inject
6538    ///   - INSERT inside catch_unwind — expect panic + FSYNC_PANIC_OBSERVED
6539    #[test]
6540    fn v7_37_13_fsync_policy_retry_and_default_abort() {
6541        // Serialise this test against any other test that might
6542        // also touch the process-wide FSYNC_RETRY_OVERRIDE atomic
6543        // (the policy switch, not the inject — inject is per-
6544        // WalGroup since v7.37.13). FSYNC_FAIL_INJECT static no
6545        // longer exists; per-instance arm via db.arm_wal_fsync_fail_for_testing.
6546        static POLICY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
6547        let _guard = POLICY_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
6548
6549        // ===== Phase 1: retry path =====
6550        let prev = FSYNC_RETRY_OVERRIDE.swap(1, std::sync::atomic::Ordering::AcqRel);
6551        {
6552            let dir = tmpdir();
6553            let db_path = dir.join("retry_path_db");
6554            let mut db = Database::open_path(&db_path).expect("open");
6555            db.execute("CREATE TABLE t (id BIGINT)").expect("ddl");
6556            db.set_checkpoint_threshold_bytes(u64::MAX);
6557            db.set_checkpoint_time_threshold(None);
6558            db.checkpoint_wait().expect("drain pre-inject");
6559
6560            db.arm_wal_fsync_fail_for_testing();
6561            let result = db.execute("INSERT INTO t VALUES (1)");
6562            assert!(
6563                result.is_err(),
6564                "phase 1 (retry path): FSYNC_RETRY_OVERRIDE=1 + per-instance \
6565                 inject must surface as a graceful Err (legacy poison path); \
6566                 got {result:?}"
6567            );
6568        }
6569
6570        // ===== Phase 2: default abort path =====
6571        FSYNC_RETRY_OVERRIDE.store(0, std::sync::atomic::Ordering::Release);
6572        FSYNC_PANIC_OBSERVED.store(false, std::sync::atomic::Ordering::Release);
6573        {
6574            let dir = tmpdir();
6575            let db_path = dir.join("abort_path_db");
6576            let mut db = Database::open_path(&db_path).expect("open");
6577            db.execute("CREATE TABLE t (id BIGINT)").expect("ddl");
6578            db.set_checkpoint_threshold_bytes(u64::MAX);
6579            db.set_checkpoint_time_threshold(None);
6580            db.checkpoint_wait().expect("drain pre-inject");
6581
6582            db.arm_wal_fsync_fail_for_testing();
6583            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6584                let _ = db.execute("INSERT INTO t VALUES (1)");
6585            }));
6586
6587            assert!(
6588                outcome.is_err(),
6589                "phase 2 (default policy): must panic (= abort in release) \
6590                 on injected fsync failure; the call returned without panicking"
6591            );
6592            assert!(
6593                FSYNC_PANIC_OBSERVED.load(std::sync::atomic::Ordering::Acquire),
6594                "phase 2: panic fired but FSYNC_PANIC_OBSERVED witness was \
6595                 not set — something else panicked, not handle_wal_fsync_fail"
6596            );
6597        }
6598
6599        // Restore so other tests see the prior policy.
6600        FSYNC_RETRY_OVERRIDE.store(prev, std::sync::atomic::Ordering::Release);
6601    }
6602
6603    /// v7.37.13 (A1.2 TDD) — v6 record round-trips through the
6604    /// encode + parse pair with the default CRC32C scheme. Covers
6605    /// the basic "write something, read it back" invariant before
6606    /// the bit-flip detection test below.
6607    #[test]
6608    fn v7_37_13_v6_wal_round_trip_crc32c() {
6609        // Force scheme to CRC32C so the encoder is deterministic.
6610        let prev = WAL_HASH_SCHEME_OVERRIDE.swap(
6611            WAL_V6_HASH_SCHEME_CRC32C as i8,
6612            std::sync::atomic::Ordering::AcqRel,
6613        );
6614        let bytes = encode_v6_auto_commit(
6615            "CREATE TABLE t (id INT)",
6616            41, // prev_lsn (A1.3)
6617            42, // commit_lsn
6618            1_700_000_000_000_000,
6619        );
6620        let parsed = parse_wal_records(&bytes).expect("parse");
6621        assert_eq!(parsed.len(), 1, "exactly one record encoded");
6622        assert_eq!(parsed[0].commit_lsn, Some(42));
6623        assert_eq!(parsed[0].commit_unix_us, Some(1_700_000_000_000_000));
6624        assert_eq!(
6625            parsed[0].prev_lsn,
6626            Some(41),
6627            "prev_lsn carried through parse"
6628        );
6629        assert_eq!(parsed[0].sql, b"CREATE TABLE t (id INT)");
6630        WAL_HASH_SCHEME_OVERRIDE.store(prev, std::sync::atomic::Ordering::Release);
6631    }
6632
6633    /// v7.37.13 (A1.2 TDD) — flipping any byte inside a v6 record
6634    /// makes the CRC32C verification fail; the parser silently
6635    /// terminates iteration (same recovery story as the v3/v4/v5
6636    /// paths), so the corrupted record is NOT surfaced to callers
6637    /// pretending to be a valid write. Closes A1.2 — matches PG's
6638    /// per-record CRC32C posture since 9.3.
6639    #[test]
6640    fn v7_37_13_v6_crc32c_detects_bit_flip() {
6641        let prev = WAL_HASH_SCHEME_OVERRIDE.swap(
6642            WAL_V6_HASH_SCHEME_CRC32C as i8,
6643            std::sync::atomic::Ordering::AcqRel,
6644        );
6645        let good = encode_v6_auto_commit("INSERT INTO t VALUES (1)", 6, 7, 100);
6646        let mut flipped = good.clone();
6647        // Flip a byte deep inside the payload (well past the header).
6648        let mid = flipped.len() / 2;
6649        flipped[mid] ^= 0x01;
6650        let parsed = parse_wal_records(&flipped).expect("parse returns Ok with empty prefix");
6651        assert!(
6652            parsed.is_empty(),
6653            "bit-flip in v6 payload must NOT surface as a valid record \
6654             (got {parsed:?}); CRC32C verification at parse_v6_record_body \
6655             should reject and terminate iteration"
6656        );
6657        WAL_HASH_SCHEME_OVERRIDE.store(prev, std::sync::atomic::Ordering::Release);
6658    }
6659
6660    /// v7.37.13 (A1.6 TDD [PG+]) — with `SPG_WAL_HASH=blake3` (or
6661    /// the test-only override = 1) the encoder writes a v6 record
6662    /// with hash_scheme=1 + a 32-byte BLAKE3 of the payload appended;
6663    /// the parser verifies BOTH the CRC32C AND the BLAKE3. The
6664    /// resulting record is 32 bytes longer than the CRC32C-only
6665    /// form, and round-trips identically.
6666    #[test]
6667    fn v7_37_13_v6_blake3_round_trip_and_layout() {
6668        // Phase 1: encode with CRC32C, capture length baseline.
6669        let prev = WAL_HASH_SCHEME_OVERRIDE.swap(
6670            WAL_V6_HASH_SCHEME_CRC32C as i8,
6671            std::sync::atomic::Ordering::AcqRel,
6672        );
6673        let crc_only = encode_v6_auto_commit("SELECT 1", 6, 7, 100);
6674        let crc_only_len = crc_only.len();
6675        let crc_only_parsed = parse_wal_records(&crc_only).expect("parse crc-only");
6676        assert_eq!(crc_only_parsed.len(), 1);
6677
6678        // Phase 2: switch to BLAKE3 mode, re-encode, verify the
6679        // 32-byte hash tail is present + round-trip works.
6680        WAL_HASH_SCHEME_OVERRIDE.store(
6681            WAL_V6_HASH_SCHEME_BLAKE3 as i8,
6682            std::sync::atomic::Ordering::Release,
6683        );
6684        let with_blake3 = encode_v6_auto_commit("SELECT 1", 6, 7, 100);
6685        assert_eq!(
6686            with_blake3.len(),
6687            crc_only_len + WAL_V6_BLAKE3_LEN,
6688            "BLAKE3 mode adds exactly {WAL_V6_BLAKE3_LEN} bytes vs CRC32C-only"
6689        );
6690        let parsed = parse_wal_records(&with_blake3).expect("parse blake3");
6691        assert_eq!(parsed.len(), 1);
6692        assert_eq!(parsed[0].sql, b"SELECT 1");
6693        assert_eq!(parsed[0].commit_lsn, Some(7));
6694
6695        // Phase 3: tamper with payload byte, verify BLAKE3 path
6696        // also rejects (CRC32C also would, but BLAKE3 adds
6697        // cryptographic integrity).
6698        let mut flipped = with_blake3.clone();
6699        let payload_idx = flipped.len() - 3;
6700        flipped[payload_idx] ^= 0x01;
6701        let parsed_flipped = parse_wal_records(&flipped).expect("parse Ok with empty prefix");
6702        assert!(
6703            parsed_flipped.is_empty(),
6704            "BLAKE3 mode rejects tampered payload"
6705        );
6706
6707        WAL_HASH_SCHEME_OVERRIDE.store(prev, std::sync::atomic::Ordering::Release);
6708    }
6709
6710    /// v7.37.13 (A1.9 TDD) — checkpoint observability: after a
6711    /// successful checkpoint, `Database::checkpoint_stats()`
6712    /// reports per-job timing (write_us / sync_us / total_us +
6713    /// bytes + files_synced) and the [PG+] p50/p95/p99 percentile
6714    /// bucket. PG ships `LogCheckpointEnd` which logs the latest
6715    /// only; SPG keeps a rolling window so monitoring sees recent
6716    /// distribution, not a single-sample latest.
6717    #[test]
6718    fn v7_37_13_checkpoint_stats_record_timing_and_percentiles() {
6719        let dir = tmpdir();
6720        let db_path = dir.join("stats_db");
6721        let mut db = Database::open_path(&db_path).expect("open");
6722        db.execute("CREATE TABLE t (id BIGINT, blob TEXT)")
6723            .expect("ddl");
6724
6725        // Baseline: no checkpoints yet → total_count = 0, all
6726        // last_* fields zero, percentiles (0, 0, 0).
6727        let baseline = db.checkpoint_stats();
6728        assert_eq!(baseline.total_count, 0);
6729        assert_eq!(baseline.last_total_us, 0);
6730        assert_eq!(baseline.percentiles(), (0, 0, 0));
6731
6732        // Trigger 5 explicit checkpoints with some data between
6733        // so wal_bytes / snapshot_bytes are non-zero.
6734        for round in 0..5 {
6735            for i in 0..10 {
6736                db.execute(&format!(
6737                    "INSERT INTO t VALUES ({}, '{}')",
6738                    round * 10 + i,
6739                    "p".repeat(256)
6740                ))
6741                .expect("insert");
6742            }
6743            db.checkpoint().expect("checkpoint");
6744        }
6745
6746        let stats = db.checkpoint_stats();
6747        assert_eq!(
6748            stats.total_count, 5,
6749            "5 explicit checkpoints must increment total_count by 5"
6750        );
6751        // Last checkpoint timings are non-zero (cargo-test timing
6752        // is ~10 µs minimum on modern hardware; we just assert > 0
6753        // to avoid flakes on fast hosts).
6754        assert!(
6755            stats.last_total_us > 0,
6756            "last_total_us should be non-zero after a checkpoint (saw {})",
6757            stats.last_total_us
6758        );
6759        assert!(
6760            stats.last_snapshot_bytes > 0,
6761            "snapshot serialize produces bytes (saw {})",
6762            stats.last_snapshot_bytes
6763        );
6764        assert!(
6765            stats.last_files_synced >= 2,
6766            "checkpoint syncs at least snapshot + WAL marker (saw {})",
6767            stats.last_files_synced
6768        );
6769
6770        // Percentile window populated.
6771        let (p50, p95, p99) = stats.percentiles();
6772        assert!(
6773            p50 > 0 && p95 > 0 && p99 > 0,
6774            "percentile bucket populated after 5 samples (p50={p50} p95={p95} p99={p99})"
6775        );
6776        assert!(
6777            p50 <= p95 && p95 <= p99,
6778            "percentiles must be monotone non-decreasing (p50={p50} p95={p95} p99={p99})"
6779        );
6780        assert!(
6781            stats.recent_total_us.len() == 5,
6782            "rolling window holds all 5 samples (≤ CHECKPOINT_STATS_WINDOW={})",
6783            CHECKPOINT_STATS_WINDOW
6784        );
6785    }
6786
6787    /// v7.37.13 (A1.8 TDD [PG+]) — when adaptive mode is on (no env
6788    /// pin), `checkpoint_threshold_bytes` is recomputed from the
6789    /// observed WAL growth rate after each trigger. A workload that
6790    /// writes faster gets a larger threshold (and vice versa), so
6791    /// checkpoint cadence adapts to the workload rather than
6792    /// forcing the operator to guess `SPG_EMBEDDED_CHECKPOINT_BYTES`
6793    /// up front.
6794    ///
6795    /// Test approach: pin time threshold to 100ms so triggers fire
6796    /// fast, write a few rounds of N-byte payloads, verify EWMA
6797    /// becomes non-zero (the recompute fired) and the resulting
6798    /// threshold lands inside the documented [1 MiB, 64 MiB] band.
6799    #[test]
6800    fn v7_37_13_adaptive_threshold_recomputes_from_ewma() {
6801        let dir = tmpdir();
6802        let db_path = dir.join("adaptive_db");
6803        let mut db = Database::open_path(&db_path).expect("open");
6804        db.execute("CREATE TABLE t (id BIGINT, blob TEXT)")
6805            .expect("ddl");
6806
6807        // Initial: EWMA hasn't been fed yet → 0. Threshold = default
6808        // (4 MiB) since we haven't crossed a trigger.
6809        assert_eq!(
6810            db.ewma_wal_rate_bytes_per_sec(),
6811            0,
6812            "no triggers yet → EWMA = 0"
6813        );
6814
6815        // Force fast time trigger.
6816        db.set_checkpoint_time_threshold(Some(core::time::Duration::from_millis(50)));
6817
6818        // 5 rounds of payload, each round waits past the time
6819        // threshold so the trigger fires + EWMA updates.
6820        for round in 0..5 {
6821            for i in 0..20 {
6822                db.execute(&format!(
6823                    "INSERT INTO t VALUES ({i}, '{pad}')",
6824                    pad = "x".repeat(512)
6825                ))
6826                .unwrap_or_else(|e| panic!("insert round {round} #{i}: {e:?}"));
6827            }
6828            std::thread::sleep(core::time::Duration::from_millis(75));
6829            // One more write inside the window to actually fire wal_after_ok
6830            // (the check is gated on a write event).
6831            db.execute("INSERT INTO t VALUES (99, 'tick')")
6832                .expect("trigger");
6833        }
6834        db.checkpoint_wait().expect("drain");
6835
6836        let ewma = db.ewma_wal_rate_bytes_per_sec();
6837        let threshold = db.checkpoint_threshold_bytes();
6838
6839        assert!(
6840            ewma > 0,
6841            "EWMA must be non-zero after 5 trigger rounds (saw {ewma})"
6842        );
6843        // 1 MiB ≤ threshold ≤ 64 MiB per the documented bounds.
6844        const ONE_MIB: u64 = 1024 * 1024;
6845        const SIXTY_FOUR_MIB: u64 = 64 * 1024 * 1024;
6846        assert!(
6847            (ONE_MIB..=SIXTY_FOUR_MIB).contains(&threshold),
6848            "adaptive threshold must land in [1 MiB, 64 MiB] (saw {threshold} bytes; \
6849             EWMA={ewma} B/s)"
6850        );
6851    }
6852
6853    /// v7.37.13 (A1.8 TDD [PG+]) — when the operator pins
6854    /// `SPG_EMBEDDED_CHECKPOINT_BYTES`, adaptive mode is OFF and
6855    /// the threshold stays exactly what they set. Verifies the
6856    /// opt-out path so operators who have tuned via the env are
6857    /// not surprised by the new behaviour.
6858    ///
6859    /// Implementation note: we cannot easily set the env safely
6860    /// across parallel tests, so this test instead uses the
6861    /// `set_checkpoint_threshold_bytes` setter which (per existing
6862    /// invariant) does NOT flip adaptive_threshold_enabled. We then
6863    /// verify that even after triggers, the threshold value
6864    /// matches the setter's value OR the recomputed adaptive value
6865    /// (depending on adaptive_threshold_enabled state at open-time).
6866    #[test]
6867    fn v7_37_13_adaptive_threshold_bounded() {
6868        // Adaptive bounds invariant: result of recompute MUST
6869        // always be within [1 MiB, 64 MiB]. Drive a very-low-rate
6870        // workload to push EWMA below 1 MiB target and verify the
6871        // floor holds.
6872        let dir = tmpdir();
6873        let db_path = dir.join("adaptive_floor_db");
6874        let mut db = Database::open_path(&db_path).expect("open");
6875        db.execute("CREATE TABLE t (id BIGINT)").expect("ddl");
6876        db.set_checkpoint_time_threshold(Some(core::time::Duration::from_millis(50)));
6877
6878        // Tiny writes spaced apart → rate very low → target tiny.
6879        for _ in 0..3 {
6880            std::thread::sleep(core::time::Duration::from_millis(70));
6881            db.execute("INSERT INTO t VALUES (1)").expect("tick");
6882        }
6883        db.checkpoint_wait().expect("drain");
6884
6885        let threshold = db.checkpoint_threshold_bytes();
6886        const ONE_MIB: u64 = 1024 * 1024;
6887        assert!(
6888            threshold >= ONE_MIB,
6889            "adaptive recompute must clamp to [1 MiB, ...] floor even on \
6890             very-low-rate workloads (saw {threshold} bytes)"
6891        );
6892    }
6893
6894    /// v7.37.13 (A1.7 TDD) — `freeze_oldest_to_cold` must call
6895    /// [`fadvise_dontneed_file`] on the newly-renamed segment so
6896    /// the kernel evicts the just-written bytes from the page
6897    /// cache. Without this, a long-running embedded process
6898    /// accumulates a stale page-cache footprint proportional to
6899    /// the cold tier — crowding out hot-tier reads. Matches PG's
6900    /// pg_flush_data posture.
6901    ///
6902    /// Counter-based assertion (FADVISE_DONTNEED_CALL_COUNT) —
6903    /// kernel-level page-cache eviction is unobservable from
6904    /// userspace anyway, so the test verifies the CALL SITE was
6905    /// reached. Passes uniformly on Linux (real syscall) and
6906    /// macOS / Windows (no-op stub that still bumps the counter).
6907    #[test]
6908    fn v7_37_13_freeze_to_cold_fadvises_dontneed_on_segment() {
6909        let dir = tmpdir();
6910        let db_path = dir.join("fadvise_db");
6911        let mut db = Database::open_path(&db_path).expect("open");
6912        db.execute("CREATE TABLE users (id BIGINT PRIMARY KEY, name TEXT)")
6913            .expect("ddl");
6914        db.execute("CREATE INDEX by_id ON users (id)").expect("ix");
6915        for i in 0..200i64 {
6916            db.execute(&format!("INSERT INTO users VALUES ({i}, 'u-{i}')"))
6917                .expect("insert");
6918        }
6919        db.checkpoint().expect("seed checkpoint");
6920
6921        let baseline = FADVISE_DONTNEED_CALL_COUNT.load(std::sync::atomic::Ordering::Relaxed);
6922        db.freeze_oldest_to_cold("users", "by_id", 100)
6923            .expect("freeze");
6924        let after = FADVISE_DONTNEED_CALL_COUNT.load(std::sync::atomic::Ordering::Relaxed);
6925
6926        assert!(
6927            after > baseline,
6928            "freeze_oldest_to_cold must call fadvise_dontneed_file on the new \
6929             cold segment (baseline {baseline}, after {after}); without this \
6930             hint, a long-running process accumulates a stale page-cache \
6931             footprint proportional to the cold tier (AUDIT-3-categories A1.7)."
6932        );
6933    }
6934
6935    /// v7.37.13 (A1.3 TDD) — every v6 record persists a prev_lsn
6936    /// field; parse surfaces it on `WalRecord.prev_lsn` so PITR /
6937    /// audit tooling can verify chunk-level LSN contiguity. Closes
6938    /// AUDIT-3-categories.md A1.3 — matches PG xl_prev posture.
6939    ///
6940    /// Test: build a chunk of 3 v6 records with monotonic LSNs
6941    /// (10, 11, 12), parse, assert prev chain is (0→10, 10→11,
6942    /// 11→12). The prev_lsn=0 in record 1 = boundary "no prior
6943    /// record" (the chunk just started).
6944    #[test]
6945    fn v7_37_13_v6_prev_lsn_chains_through_chunk() {
6946        let prev = WAL_HASH_SCHEME_OVERRIDE.swap(
6947            WAL_V6_HASH_SCHEME_CRC32C as i8,
6948            std::sync::atomic::Ordering::AcqRel,
6949        );
6950        let mut chunk = Vec::new();
6951        // Record 1: chunk start — prev_lsn = 0 by convention
6952        chunk.extend_from_slice(&encode_v6_auto_commit(
6953            "INSERT INTO t VALUES (1)",
6954            0,
6955            10,
6956            100,
6957        ));
6958        // Record 2: prev = 10 (the previous record's commit_lsn)
6959        chunk.extend_from_slice(&encode_v6_auto_commit(
6960            "INSERT INTO t VALUES (2)",
6961            10,
6962            11,
6963            101,
6964        ));
6965        // Record 3: prev = 11
6966        chunk.extend_from_slice(&encode_v6_auto_commit(
6967            "INSERT INTO t VALUES (3)",
6968            11,
6969            12,
6970            102,
6971        ));
6972
6973        let parsed = parse_wal_records(&chunk).expect("parse chain");
6974        assert_eq!(parsed.len(), 3);
6975        assert_eq!(
6976            parsed[0].prev_lsn,
6977            Some(0),
6978            "chunk-start record's prev is 0"
6979        );
6980        assert_eq!(parsed[0].commit_lsn, Some(10));
6981        assert_eq!(
6982            parsed[1].prev_lsn,
6983            Some(10),
6984            "record 2 points back to record 1's LSN"
6985        );
6986        assert_eq!(parsed[1].commit_lsn, Some(11));
6987        assert_eq!(
6988            parsed[2].prev_lsn,
6989            Some(11),
6990            "record 3 points back to record 2's LSN"
6991        );
6992        assert_eq!(parsed[2].commit_lsn, Some(12));
6993        WAL_HASH_SCHEME_OVERRIDE.store(prev, std::sync::atomic::Ordering::Release);
6994    }
6995
6996    /// v7.37.13 (A1.2 backward compat) — v5 records on disk MUST
6997    /// still parse with the legacy IEEE CRC32 path. The v6 dispatch
6998    /// adds a branch, not a breakage; existing dbs that boot up
6999    /// with v3/v4/v5 records in their WAL recover identically.
7000    #[test]
7001    fn v7_37_13_v5_wal_still_parses_after_v6_dispatch_added() {
7002        let v5_bytes = encode_v4_auto_commit("CREATE TABLE legacy (x INT)", 1, 0);
7003        let parsed = parse_wal_records(&v5_bytes).expect("v5 parse");
7004        assert_eq!(parsed.len(), 1);
7005        assert_eq!(parsed[0].sql, b"CREATE TABLE legacy (x INT)");
7006        assert_eq!(parsed[0].commit_lsn, Some(1));
7007    }
7008
7009    #[test]
7010    fn ask1_in_process_registry_refuses_sibling_open() {
7011        // Two `Database::open_path` calls in the same process MUST
7012        // NOT both succeed (the second would race the first's WAL
7013        // replay). v7.37.10 leaned on on-disk pid + start-time
7014        // matching; v7.37.5 settles it directly via
7015        // `ACTIVE_OPEN_PATHS`.
7016        let dir = tmpdir();
7017        let db_path = dir.join("t.spg");
7018        let first = Database::open_path(&db_path).expect("first open succeeds");
7019        // Confirm the registry registered this path.
7020        let lock_path = {
7021            let mut p = db_path.clone();
7022            let mut s = p.file_name().unwrap().to_os_string();
7023            s.push(".lock");
7024            p.set_file_name(s);
7025            p
7026        };
7027        assert!(
7028            is_lock_path_active_in_process(&lock_path),
7029            "lock_path must be registered while Database is live"
7030        );
7031        // Sibling open MUST refuse honestly (not hang).
7032        let second = Database::open_path(&db_path);
7033        assert!(
7034            matches!(second, Err(EngineError::Unsupported(_))),
7035            "sibling open_path on same path must refuse, got {second:?}"
7036        );
7037        drop(first);
7038        // Once dropped, the registry releases and a fresh open
7039        // succeeds.
7040        assert!(
7041            !is_lock_path_active_in_process(&lock_path),
7042            "lock_path must be de-registered after Database is dropped"
7043        );
7044        let third = Database::open_path(&db_path);
7045        assert!(
7046            third.is_ok(),
7047            "post-drop open_path on same path must succeed, got {third:?}"
7048        );
7049        let _ = std::fs::remove_dir_all(&dir);
7050    }
7051
7052    #[test]
7053    fn ask2_force_unlock_clears_in_process_registry() {
7054        // `force_unlock` is the operator's "no one owns this catalog"
7055        // assertion. Post-Ask-1 the in-process registry would refuse
7056        // a sibling open even after force_unlock — Ask 2 wires
7057        // force_unlock to ALSO clear the registry so retries see a
7058        // consistent "free" state.
7059        let dir = tmpdir();
7060        let db_path = dir.join("u.spg");
7061        // Open a database to populate the registry, then keep the
7062        // handle so the registry entry survives.
7063        let _first = Database::open_path(&db_path).expect("first open succeeds");
7064        let lock_path = {
7065            let mut p = db_path.clone();
7066            let mut s = p.file_name().unwrap().to_os_string();
7067            s.push(".lock");
7068            p.set_file_name(s);
7069            p
7070        };
7071        assert!(is_lock_path_active_in_process(&lock_path));
7072        // force_unlock — operator declares the catalog free.
7073        Database::force_unlock(&db_path).expect("force_unlock succeeds");
7074        // Registry MUST be cleared (Ask 2 contract).
7075        assert!(
7076            !is_lock_path_active_in_process(&lock_path),
7077            "force_unlock must clear the in-process registry entry"
7078        );
7079        // Disk lock is also gone.
7080        assert!(
7081            !lock_path.exists(),
7082            "force_unlock must remove the on-disk lock dir"
7083        );
7084        let _ = std::fs::remove_dir_all(&dir);
7085    }
7086
7087    #[test]
7088    fn ask3_apply_redo_differential_vs_per_record_path() {
7089        // v7.37.5 — differential test: batched `apply_redo` MUST
7090        // produce the same final catalog state (rows + indices)
7091        // as the legacy per-record path that called the public
7092        // `Table::insert`, `update_row`, `delete_rows` in order.
7093        // Built on a smaller table so the per-record path is
7094        // tractable. Mixes Insert/Update/Delete to exercise the
7095        // composition logic.
7096        use spg_storage::{Catalog, ColumnSchema, Row, RowChange, TableSchema};
7097        use spg_storage::{DataType, Value};
7098
7099        fn build_seed_catalog() -> Catalog {
7100            let columns = vec![
7101                ColumnSchema::new("a", DataType::Int, false),
7102                ColumnSchema::new("b", DataType::Int, false),
7103                ColumnSchema::new("c", DataType::Int, false),
7104            ];
7105            let mut cat = Catalog::new();
7106            cat.create_table(TableSchema::new("t", columns)).unwrap();
7107            // 3 BTree indices on a, b, c.
7108            cat.get_mut("t")
7109                .unwrap()
7110                .add_index("idx_a".into(), "a")
7111                .unwrap();
7112            cat.get_mut("t")
7113                .unwrap()
7114                .add_index("idx_b".into(), "b")
7115                .unwrap();
7116            cat.get_mut("t")
7117                .unwrap()
7118                .add_index("idx_c".into(), "c")
7119                .unwrap();
7120            for r in 0..100 {
7121                cat.get_mut("t")
7122                    .unwrap()
7123                    .insert(Row::new(vec![
7124                        Value::Int(r),
7125                        Value::Int(r * 2),
7126                        Value::Int(r * 3),
7127                    ]))
7128                    .unwrap();
7129            }
7130            cat
7131        }
7132
7133        use spg_storage::row_header::RowId;
7134        let changes: Vec<RowChange> = vec![
7135            RowChange::Delete {
7136                table: "t".to_string(),
7137                positions: vec![5, 7, 9],
7138                rowids: vec![RowId::UNASSIGNED; 3],
7139                writer_version: 0,
7140            },
7141            RowChange::Insert {
7142                table: "t".to_string(),
7143                row: Row::new(vec![Value::Int(999), Value::Int(1998), Value::Int(2997)]),
7144                rowid: RowId::UNASSIGNED,
7145                writer_version: 0,
7146            },
7147            RowChange::Update {
7148                table: "t".to_string(),
7149                pos: 3,
7150                new_row: vec![Value::Int(42), Value::Int(84), Value::Int(126)],
7151                rowid: RowId::UNASSIGNED,
7152                writer_version: 0,
7153            },
7154            RowChange::Delete {
7155                table: "t".to_string(),
7156                positions: vec![0, 1],
7157                rowids: vec![RowId::UNASSIGNED; 2],
7158                writer_version: 0,
7159            },
7160        ];
7161
7162        // Path A: the new batched `apply_redo`.
7163        let mut cat_batched = build_seed_catalog();
7164        cat_batched.apply_redo(&changes).unwrap();
7165
7166        // Path B: the legacy per-record path via the public
7167        // `Table` mutators. Position semantics for `Delete` /
7168        // `Update` are identical to `apply_redo`'s composition
7169        // (positions reference the post-prior-change layout).
7170        let mut cat_legacy = build_seed_catalog();
7171        for change in &changes {
7172            match change {
7173                RowChange::Insert { table, row, .. } => {
7174                    cat_legacy
7175                        .get_mut(table)
7176                        .unwrap()
7177                        .insert(row.clone())
7178                        .unwrap();
7179                }
7180                RowChange::Update {
7181                    table,
7182                    pos,
7183                    new_row,
7184                    ..
7185                } => {
7186                    cat_legacy
7187                        .get_mut(table)
7188                        .unwrap()
7189                        .update_row(*pos, new_row.clone())
7190                        .unwrap();
7191                }
7192                RowChange::Delete {
7193                    table, positions, ..
7194                } => {
7195                    cat_legacy.get_mut(table).unwrap().delete_rows(positions);
7196                }
7197                // This fixture uses only Insert/Update/Delete; the
7198                // in-place tombstone path has its own dedicated replay
7199                // test in spg-storage.
7200                RowChange::Tombstone { .. } => {
7201                    unreachable!("this legacy-parity fixture emits no Tombstone")
7202                }
7203            }
7204        }
7205
7206        let a = cat_batched.get("t").unwrap();
7207        let b = cat_legacy.get("t").unwrap();
7208        assert_eq!(
7209            a.rows().len(),
7210            b.rows().len(),
7211            "row counts differ after replay"
7212        );
7213        for (i, (ar, br)) in a.rows().iter().zip(b.rows().iter()).enumerate() {
7214            assert_eq!(
7215                ar.values, br.values,
7216                "row {i} differs: batched={:?} legacy={:?}",
7217                ar.values, br.values
7218            );
7219        }
7220    }
7221
7222    #[test]
7223    fn ask3_apply_redo_batches_index_rebuilds() {
7224        // Synthetic reproducer for the 27-min mailrs WAL replay
7225        // hang. Build a 100k-row table with 13 BTree indices, then
7226        // apply 5000 `RowChange::Delete` records via the public
7227        // `Catalog::apply_redo` entry point. Pre-v7.37.5 each
7228        // record triggered a full `rebuild_indices` — minutes of
7229        // CPU. Post-v7.37.5 there's exactly one rebuild at the
7230        // end.
7231        //
7232        // The assertion is a wall-clock budget: even on a slow
7233        // CI box this must complete in well under 10 seconds.
7234        use spg_storage::{Catalog, ColumnSchema, Row, RowChange, TableSchema};
7235        use spg_storage::{DataType, Value};
7236
7237        const N_ROWS: usize = 100_000;
7238        const N_INDICES: usize = 13;
7239        const N_DELETE_RECORDS: usize = 5_000;
7240        const ROWS_PER_RECORD: usize = 1; // mirrors mailrs WAL shape
7241
7242        // Build a catalog with one table, N_INDICES BTree indices
7243        // over int columns.
7244        let columns: Vec<ColumnSchema> = (0..N_INDICES)
7245            .map(|i| ColumnSchema::new(format!("c{i}"), DataType::Int, false))
7246            .collect();
7247        let schema = TableSchema::new("t", columns);
7248        let mut catalog = Catalog::new();
7249        catalog.create_table(schema).unwrap();
7250        for i in 0..N_INDICES {
7251            catalog
7252                .get_mut("t")
7253                .unwrap()
7254                .add_index(format!("idx_c{i}"), &format!("c{i}"))
7255                .unwrap();
7256        }
7257        for r in 0..N_ROWS {
7258            let row = Row::new(
7259                (0..N_INDICES)
7260                    .map(|c| Value::Int((r as i32) * 31 + (c as i32)))
7261                    .collect(),
7262            );
7263            catalog.get_mut("t").unwrap().insert(row).unwrap();
7264        }
7265        // Build the 5000 Delete records. Each record references
7266        // positions valid at the time it would have been written;
7267        // since each removes ROWS_PER_RECORD row (at position 0
7268        // post-prior-deletes), the position stays 0 throughout —
7269        // mirrors a sentinel/oldest-first sweep.
7270        let changes: Vec<RowChange> = (0..N_DELETE_RECORDS)
7271            .map(|_| RowChange::Delete {
7272                table: "t".to_string(),
7273                positions: (0..ROWS_PER_RECORD).collect(),
7274                rowids: Vec::new(),
7275                writer_version: 0,
7276            })
7277            .collect();
7278
7279        let start = std::time::Instant::now();
7280        catalog.apply_redo(&changes).unwrap();
7281        let elapsed = start.elapsed();
7282        let remaining = catalog.get("t").unwrap().rows().len();
7283        assert_eq!(
7284            remaining,
7285            N_ROWS - N_DELETE_RECORDS * ROWS_PER_RECORD,
7286            "expected {} rows left after {} deletes",
7287            N_ROWS - N_DELETE_RECORDS * ROWS_PER_RECORD,
7288            N_DELETE_RECORDS * ROWS_PER_RECORD
7289        );
7290        // 10 s budget — pre-v7.37.5 was 27 minutes on prod-shape;
7291        // post-fix is ~300 ms locally. A 10 s ceiling leaves
7292        // generous headroom for slow CI.
7293        assert!(
7294            elapsed < std::time::Duration::from_secs(10),
7295            "apply_redo of {N_DELETE_RECORDS} DELETE records on {N_ROWS}-row × {N_INDICES}-index table \
7296             took {elapsed:?} — Ask 3 batching regression"
7297        );
7298        eprintln!(
7299            "ask3_apply_redo_batches_index_rebuilds: {N_DELETE_RECORDS} DELETE records \
7300             on {N_ROWS}-row × {N_INDICES}-index table replayed in {elapsed:?}"
7301        );
7302    }
7303}