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};
80pub use spg_storage::{ColumnSchema, DataType, Value, ValueOwned};
81
82/// v7.16.0 — handle for a parsed-and-planned SQL statement.
83/// Hand off to [`Database::execute_prepared`] / [`Database::query_prepared`]
84/// with a `&[Value]` slice carrying the bind parameters (PG-style
85/// `$1`, `$2`, … positional). Cheap to `Clone`; the underlying AST
86/// is shared by handle copies and cloned per bind call by the
87/// engine's executor.
88///
89/// The handle holds a snapshot of the AST at prepare time. If
90/// the engine's plan cache evicts the entry between prepare and
91/// execute (e.g. ANALYZE bumps the statistics version) the
92/// stored AST keeps working — `execute_prepared` operates on
93/// the handle's clone, not the cache entry.
94#[derive(Debug, Clone)]
95pub struct Statement {
96    /// The parsed + planned AST. `spg-engine::prepare_cached`
97    /// returns it as a clone of the cached plan, so any rewrite
98    /// passes (`expand_group_by_all`, `reorder_joins`, …) have
99    /// already run.
100    pub(crate) stmt: ParsedStatement,
101    /// Original SQL source, kept for `Display` / debug only.
102    /// WAL persistence renders from the AST so a bind-time
103    /// rewrite of `$1..$N` survives replay.
104    pub(crate) sql: String,
105}
106
107impl Statement {
108    /// Borrow the original SQL source — useful for tracing and
109    /// debug logs. WAL replay does NOT use this; it serialises
110    /// the bind-final AST instead.
111    #[must_use]
112    pub fn sql(&self) -> &str {
113        &self.sql
114    }
115}
116
117/// v7.16.0 — internal WAL helper. Mirrors what
118/// `Engine::execute_prepared` does to the cloned AST so the WAL
119/// record carries the bind-final SQL text (so replay's
120/// simple-query path reconstructs the same row state without
121/// needing the original `Statement` handle to still be alive).
122/// Errors from the underlying engine helper would only fire if
123/// the bind-final stmt referenced a placeholder past the params
124/// slice — and that case has already errored in the executor
125/// above before this helper runs, so we discard the Result here.
126fn wal_render_with_params(stmt: &mut ParsedStatement, params: &[Value<'static>]) {
127    let _ = spg_engine::substitute_placeholders(stmt, params);
128}
129
130use std::collections::BTreeMap;
131use std::fs::{File, OpenOptions};
132use std::io::Write;
133use std::path::{Path, PathBuf};
134use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
135use std::sync::{Arc, Condvar, Mutex};
136use std::thread::{self, JoinHandle};
137use std::time::{Duration, SystemTime, UNIX_EPOCH};
138
139/// v7.11.3 — wall-clock provider injected into every embedded
140/// `Engine`. Microseconds since the Unix epoch; clamps to
141/// `i64::MAX` if the system clock is far-future. Used by SQL's
142/// `NOW()` / `CURRENT_TIMESTAMP` / `CURRENT_DATE` rewrite layer
143/// so PG-idiomatic time queries work without the caller wiring
144/// their own clock.
145/// v7.36 (mailrs ask #4) — flatten an `EXPLAIN` QueryResult into
146/// the QUERY PLAN string lines. `EXPLAIN` always returns a single-
147/// column TEXT table; anything else is treated as no plan output.
148fn extract_query_plan_lines(result: QueryResult) -> Vec<String> {
149    match result {
150        QueryResult::Rows { rows, .. } => rows
151            .into_iter()
152            .filter_map(|r| {
153                r.values.into_iter().next().and_then(|v| match v {
154                    Value::Text(s) => Some(s.into_owned()),
155                    _ => None,
156                })
157            })
158            .collect(),
159        _ => Vec::new(),
160    }
161}
162
163/// v7.37.2 — auto-warm the OS page cache for cold-tier segments at
164/// `open_path` / `restore` time. Per the zero-customer-change rule
165/// the client never calls `warm_up_cold_tier()` from app code; the
166/// catalog is server-ready when its constructor returns.
167///
168/// Budget controls:
169/// * `SPG_WARM_UP_COLD_BUDGET_MS=N` — stop warming after N ms
170///   wall-clock (best-effort; granularity is per-table). Unset =
171///   no cap.
172/// * `SPG_WARM_UP_COLD_BUDGET_MS=0` — skip warm-up entirely (escape
173///   hatch for ops that need fast restart even at the cost of the
174///   first-query cold spike).
175fn autowarm_cold_tier_on_open(db: &Database) {
176    let budget_ms = std::env::var("SPG_WARM_UP_COLD_BUDGET_MS")
177        .ok()
178        .and_then(|s| s.parse::<u64>().ok());
179    if let Some(0) = budget_ms {
180        return;
181    }
182    let start = std::time::Instant::now();
183    let touched = db.warm_up_cold_tier();
184    let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
185    let over_budget = budget_ms.is_some_and(|b| elapsed_ms > b);
186    let _ = (touched, over_budget); // future: tracing::info
187}
188
189fn wall_clock_micros() -> i64 {
190    SystemTime::now()
191        .duration_since(UNIX_EPOCH)
192        .map_or(0, |d| i64::try_from(d.as_micros()).unwrap_or(i64::MAX))
193}
194
195use spg_manifest::{CatalogManifest, ColdSegmentEntry, manifest_path as spg_manifest_path};
196
197// -- v7.1 WAL format constants (mirror `spg-server`'s) ---------
198// Kept private so callers can't mis-frame records; the v3 layout
199// is the same the server uses, so a `spg-server` boot can read a
200// database an embedded process wrote and vice versa.
201const WAL_V2_SENTINEL: u32 = 0x8000_0000;
202const WAL_V3_FLAG: u32 = 0x4000_0000;
203const WAL_V3_TYPE_AUTO_COMMIT_SQL: u8 = 0x01;
204/// v7.18 — durability checkpoint marker stays at 0x02 (skipped on replay).
205const WAL_V3_TYPE_DURABILITY_CHECKPOINT: u8 = 0x02;
206/// v7.18 PITR — auto-commit-sql record with appended (commit_lsn,
207/// commit_unix_us) fields so replay can target a specific point in
208/// time. Backward-compat: v3 records (type 0x01) keep working, the
209/// envelope flag bits are unchanged. The new type byte is the
210/// schema-version discriminator.
211const WAL_V4_TYPE_AUTO_COMMIT_SQL: u8 = 0x10;
212/// v7.18 — sentinel for "no wall clock" inside a v4 record's
213/// commit_unix_us slot. Restore-to-timestamp skips records with
214/// this sentinel (no time anchor); LSN-based restore is
215/// unaffected.
216const WAL_V4_NO_CLOCK: i64 = i64::MIN;
217/// v7.18 — extra header bytes after the type byte in a v4 record:
218/// 8 bytes commit_lsn (u64 LE) + 8 bytes commit_unix_us (i64 LE).
219const WAL_V4_EXTRA_HEADER: usize = 16;
220/// v7.18 PITR — checkpoint anchor record written to the WAL *before*
221/// the snapshot file replaces the on-disk catalog. Carries the
222/// (lsn, ts, snapshot_path) triple so restore tooling can find the
223/// matching base snapshot without scanning the filesystem. Replay
224/// dispatch skips it (same as the v3 durability marker).
225const WAL_V4_TYPE_CHECKPOINT_MARKER: u8 = 0x11;
226
227/// v7.21 (mailrs embed round-12 polish) — one COMMITted explicit
228/// transaction, flushed atomically at COMMIT time. Payload = the
229/// transaction's bind-final mutation statements joined with `";\n"`;
230/// replay re-splits via [`split_statements`] and applies in order.
231/// Same 16-byte (commit_lsn, commit_unix_us) prefix as the v4
232/// auto-commit record. The record is CRC-framed like every other
233/// record, so replay applies the whole transaction or — torn tail —
234/// none of it; a transaction can never half-resurrect.
235///
236/// Why it exists: in-transaction mutations only touch the engine's
237/// shadow catalog (`modified_catalog: false`), so the per-statement
238/// auto-commit append never fired and a COMMIT followed by a crash
239/// (no graceful Drop checkpoint) lost the transaction.
240const WAL_V4_TYPE_TX_COMMIT_SQL: u8 = 0x12;
241
242/// v7.34 (crash-recovery P0 #2) — row-level physical redo record. Same v4
243/// envelope (lsn + ts + payload + CRC) but the payload is `encode_redo_log`
244/// bytes, not SQL. Replay applies the physical [`RowChange`]s via
245/// `Engine::apply_redo` instead of re-executing — O(changed rows), not the
246/// O(records × catalog_rows) statement-replay that hung the mailrs P0.
247const WAL_V5_TYPE_ROW_REDO: u8 = 0x13;
248
249/// v7.1 — auto-checkpoint threshold. Once the WAL grows past
250/// this many bytes, the next successful `execute()` call ends
251/// with a `checkpoint()` so the WAL stays bounded. Tunable via
252/// `SPG_EMBEDDED_CHECKPOINT_BYTES` env.
253/// v7.37.8 — **default ON**. v7.34 introduced row-level redo (0x13
254/// records, replayed via `apply_redo` in O(changed rows) instead of
255/// re-executing SQL in O(records × catalog_rows)). It shipped opt-in
256/// (`SPG_WAL_ROW_REDO=1`) "during bringup", but the only meaningful
257/// prod consumer (mailrs) never had a path to set the env var (per
258/// the dogfood "zero mailrs change" contract). The result was 4
259/// recurrences of crash-recovery lock-hang between v7.37.5 and
260/// v7.37.7 — every restart paid the V4 SQL replay tax. v7.37.8
261/// flips the default ON so an `spg-X.Y.Z` upgrade alone delivers
262/// the fix; `SPG_WAL_ROW_REDO=0` remains available as an explicit
263/// operator opt-out for any caller that needs the legacy V4 SQL
264/// path (e.g. for forensics / downgrade prep). DDL still logs as
265/// SQL (hybrid log) on both sides. When this returns true,
266/// `open_path` arms the engine's redo capture.
267fn row_redo_enabled() -> bool {
268    match std::env::var("SPG_WAL_ROW_REDO").ok() {
269        Some(v) if v == "0" || v.eq_ignore_ascii_case("false") => false,
270        Some(_) | None => true,
271    }
272}
273
274fn default_checkpoint_threshold_bytes() -> u64 {
275    std::env::var("SPG_EMBEDDED_CHECKPOINT_BYTES")
276        .ok()
277        .and_then(|s| s.parse::<u64>().ok())
278        .filter(|&n| n > 0)
279        .unwrap_or(4 * 1024 * 1024)
280}
281
282/// v7.30.3 (mailrs round-26) — per-query byte budget on join/filter
283/// materialisation, default ON at 256 MiB for embed parity with the
284/// server's allocator-level `SPG_MAX_QUERY_BYTES` default. A fat
285/// backfill batch (1000 × full mail bodies) then errors with
286/// `QueryBytesExceeded` instead of walking the host into reclaim
287/// livelock. `SPG_MAX_QUERY_BYTES=0` disables; any other value
288/// overrides. NOT applied to the WAL-replay engine — replay must
289/// never fail on a tuning knob.
290fn engine_with_query_byte_budget(engine: Engine) -> Engine {
291    const DEFAULT_MAX_QUERY_BYTES: usize = 256 * 1024 * 1024;
292    match std::env::var("SPG_MAX_QUERY_BYTES")
293        .ok()
294        .and_then(|s| s.trim().parse::<usize>().ok())
295    {
296        Some(0) => engine,
297        Some(n) => engine.with_max_query_bytes(n),
298        None => engine.with_max_query_bytes(DEFAULT_MAX_QUERY_BYTES),
299    }
300}
301
302/// v7.1 — encode one v3 `auto_commit_sql` record. Layout:
303///
304/// ```text
305/// [u32 LE (len | WAL_V2_SENTINEL | WAL_V3_FLAG)]
306/// [u32 LE crc32 over (type_byte || sql_bytes)]
307/// [u8 type = 0x01]
308/// [sql bytes]
309/// ```
310fn encode_v3_auto_commit(sql: &str) -> Vec<u8> {
311    let payload = sql.as_bytes();
312    let mut crc_buf = Vec::with_capacity(1 + payload.len());
313    crc_buf.push(WAL_V3_TYPE_AUTO_COMMIT_SQL);
314    crc_buf.extend_from_slice(payload);
315    let crc = spg_crypto::crc32::crc32(&crc_buf);
316    let header = ((payload.len() as u32) | WAL_V2_SENTINEL | WAL_V3_FLAG).to_le_bytes();
317    let mut out = Vec::with_capacity(4 + 4 + 1 + payload.len());
318    out.extend_from_slice(&header);
319    out.extend_from_slice(&crc.to_le_bytes());
320    out.push(WAL_V3_TYPE_AUTO_COMMIT_SQL);
321    out.extend_from_slice(payload);
322    out
323}
324
325/// v7.20 P2 — WAL group-commit. N concurrent commits share one
326/// fsync (the 4.2 ms p50 that profile_breakdown measured as
327/// 99.2% of the durable write path).
328///
329/// Leader-follower protocol, same family as PG's group commit:
330///
331/// 1. `enqueue(record)` — called while the caller still holds
332///    the engine's write lock. Appends the encoded record to the
333///    shared buffer, returns a sequence ticket. O(memcpy).
334/// 2. Caller RELEASES the engine write lock (the next writer's
335///    mutation proceeds in parallel with this batch's fsync).
336/// 3. `wait_flushed(seq)` — if nobody is flushing, the caller
337///    elects itself leader: swaps the buffer out, writes +
338///    fsyncs ONCE for every record in the batch, marks the
339///    batch durable, wakes all followers. Otherwise it parks on
340///    the condvar until a leader covers its seq.
341///
342/// Durability contract is unchanged from v7.19: `execute()`
343/// does not return Ok until the record that describes its
344/// mutation is fsynced. The only change is N callers sharing
345/// one fsync instead of paying one each.
346///
347/// Lock order (deadlock-free): `state` then `file`; never the
348/// reverse. The leader holds `file` WITHOUT `state` during IO so
349/// enqueues continue while fsync runs.
350#[derive(Debug)]
351struct WalGroup {
352    state: Mutex<WalGroupState>,
353    cond: std::sync::Condvar,
354    /// Active chunk file handle. Separate lock from `state` so
355    /// the leader's write+fsync doesn't block concurrent
356    /// enqueues. Swapped by `checkpoint()` at rotation.
357    file: Mutex<File>,
358}
359
360#[derive(Debug)]
361struct WalGroupState {
362    /// Encoded records awaiting flush.
363    buf: Vec<u8>,
364    /// Monotonic enqueue counter (1-based).
365    enqueued_seq: u64,
366    /// Highest seq whose record is fsynced.
367    flushed_seq: u64,
368    /// True while some caller is inside the leader IO section.
369    leader_active: bool,
370    /// Sticky fatal error — a failed fsync poisons the WAL
371    /// (loud, never silent). All current + future waiters error.
372    failed: Option<String>,
373    /// Bytes written to the active chunk since rotation —
374    /// drives the auto-checkpoint trigger.
375    written_len: u64,
376}
377
378/// Ticket returned by the buffered write path; `wait()` blocks
379/// until the record it covers is durable (or the WAL is
380/// poisoned). Cheap to move across threads.
381#[derive(Debug)]
382pub struct WalTicket {
383    group: Arc<WalGroup>,
384    seq: u64,
385}
386
387/// v7.34 (crash-recovery P0 #2) — RAII reset for the WalGroup leader
388/// flag. Electing a leader sets `leader_active = true` and releases the
389/// state lock for the sleep+IO window; if a panic unwinds through that
390/// window the flag would stay true and every follower would park forever
391/// on the condvar — no one left to flush or wake them, the same
392/// total-write hang an unclean stop causes, but self-inflicted. This
393/// guard clears the flag and wakes the followers (so one re-elects) on
394/// ANY drop, including a panic unwind; the normal path disarms it after
395/// resetting the flag itself.
396struct LeaderGuard<'a> {
397    group: &'a WalGroup,
398    armed: bool,
399}
400
401impl Drop for LeaderGuard<'_> {
402    fn drop(&mut self) {
403        if self.armed {
404            let mut g = self.group.state.lock().unwrap_or_else(|e| e.into_inner());
405            g.leader_active = false;
406            drop(g);
407            self.group.cond.notify_all();
408        }
409    }
410}
411
412impl WalGroup {
413    fn new(file: File, initial_len: u64) -> Self {
414        Self {
415            state: Mutex::new(WalGroupState {
416                buf: Vec::new(),
417                enqueued_seq: 0,
418                flushed_seq: 0,
419                leader_active: false,
420                failed: None,
421                written_len: initial_len,
422            }),
423            cond: std::sync::Condvar::new(),
424            file: Mutex::new(file),
425        }
426    }
427
428    /// Append `record` to the pending batch. Returns the seq the
429    /// caller must wait on. Called under the engine write lock —
430    /// keep it O(memcpy).
431    fn enqueue(&self, record: &[u8]) -> u64 {
432        let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
433        g.buf.extend_from_slice(record);
434        g.enqueued_seq += 1;
435        g.enqueued_seq
436    }
437
438    /// Block until `seq` is durable. Leader-follower: the first
439    /// arriving waiter flushes for everyone.
440    fn wait_flushed(&self, seq: u64) -> Result<(), EngineError> {
441        let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
442        loop {
443            if let Some(e) = &g.failed {
444                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
445                    format!("WAL poisoned by earlier flush failure: {e}"),
446                )));
447            }
448            if g.flushed_seq >= seq {
449                return Ok(());
450            }
451            if !g.leader_active {
452                // Elect self leader.
453                g.leader_active = true;
454                drop(g);
455                // v7.34 — panic-safety: if anything below unwinds before
456                // `leader_active` is reset, this guard releases it +
457                // wakes a follower to re-elect (else all writers park
458                // forever). Disarmed on the normal path after the reset.
459                let mut leader_guard = LeaderGuard {
460                    group: self,
461                    armed: true,
462                };
463                // v7.20 — commit_delay (PG's same-named knob):
464                // before taking the batch, give in-flight
465                // writers a short window to enqueue so the
466                // shared fsync covers more commits. 150 µs costs
467                // ~3.5% on a solo 4.2 ms fsync but multiplies
468                // batch size under load. Tunable via
469                // SPG_COMMIT_DELAY_US (0 disables).
470                let delay = commit_delay_us();
471                if delay > 0 {
472                    std::thread::sleep(std::time::Duration::from_micros(delay));
473                }
474                let (batch, flush_to) = {
475                    let mut g2 = self.state.lock().unwrap_or_else(|e| e.into_inner());
476                    (core::mem::take(&mut g2.buf), g2.enqueued_seq)
477                };
478                let io_result: std::io::Result<()> = (|| {
479                    let mut f = self.file.lock().unwrap_or_else(|e| e.into_inner());
480                    f.write_all(&batch)?;
481                    f.sync_data()
482                })();
483                g = self.state.lock().unwrap_or_else(|e| e.into_inner());
484                g.leader_active = false;
485                leader_guard.armed = false; // normal completion — disarm
486                match io_result {
487                    Ok(()) => {
488                        g.flushed_seq = flush_to;
489                        g.written_len = g.written_len.saturating_add(batch.len() as u64);
490                    }
491                    Err(e) => {
492                        g.failed = Some(e.to_string());
493                    }
494                }
495                self.cond.notify_all();
496                //
497
498                // Loop continues: either our seq is now covered
499                // (leader path normally returns next iteration)
500                // or the error branch surfaces.
501                continue;
502            }
503            g = self.cond.wait(g).unwrap_or_else(|e| e.into_inner());
504        }
505    }
506
507    /// Drain the pending batch + flush synchronously. Caller must
508    /// guarantee no concurrent enqueues (checkpoint holds the
509    /// engine exclusively). Used before rotation so the marker
510    /// lands in the right chunk.
511    fn flush_now(&self) -> Result<(), EngineError> {
512        let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
513        if let Some(e) = &g.failed {
514            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
515                format!("WAL poisoned: {e}"),
516            )));
517        }
518        let batch = core::mem::take(&mut g.buf);
519        let flush_to = g.enqueued_seq;
520        if batch.is_empty() {
521            return Ok(());
522        }
523        drop(g);
524        let io: std::io::Result<()> = (|| {
525            let mut f = self.file.lock().unwrap_or_else(|e| e.into_inner());
526            f.write_all(&batch)?;
527            f.sync_data()
528        })();
529        let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
530        match io {
531            Ok(()) => {
532                g.flushed_seq = flush_to;
533                g.written_len = g.written_len.saturating_add(batch.len() as u64);
534                self.cond.notify_all();
535                Ok(())
536            }
537            Err(e) => {
538                g.failed = Some(e.to_string());
539                self.cond.notify_all();
540                Err(io_err(e))
541            }
542        }
543    }
544
545    /// Swap the active chunk handle (rotation). Caller flushes
546    /// first; both locks taken in canonical order.
547    fn rotate_file(&self, new_file: File) {
548        let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
549        let mut f = self.file.lock().unwrap_or_else(|e| e.into_inner());
550        *f = new_file;
551        g.written_len = 0;
552    }
553
554    fn written_len(&self) -> u64 {
555        let g = self.state.lock().unwrap_or_else(|e| e.into_inner());
556        g.written_len + g.buf.len() as u64
557    }
558}
559
560// ─────────────────────────────────────────────────────────────────────────────
561// CoW-2 (v7.34) — background-checkpoint worker.
562//
563// Splits checkpoint into two halves so the front-end pays only the cheap one:
564//   • Capture (`Database::snapshot_checkpoint_job`) — under &mut self,
565//     Arc-bump the catalog + cheap trailer/cold-segment clones + atomic
566//     commit_lsn load. Front returns to caller in microseconds.
567//   • Execute (`execute_checkpoint_job`, on the worker thread) — serialize
568//     the snapshot, tmp+rename the db / manifest files (each fsynced via
569//     the rename + dir-fsync), enqueue the v4 marker through the WalGroup
570//     (which is already thread-safe so live commits interleave fine),
571//     then rotate the chunk file.
572//
573// Replay floor is the marker LSN captured at front-end time. A crash any
574// time during the worker's sequence is safe: nothing past the previous
575// checkpoint's marker can have been forgotten until the new marker hits
576// the WAL, and live writes between the two go into the same chunk under
577// the old marker — replay re-applies them after restoring the (older)
578// snapshot. snapshot+manifest atomicity (D10) is unchanged from the sync
579// path — CoW-4 tightens it later.
580//
581// Single-instance: a state machine of {pending, inflight} so a new
582// trigger fires only when the worker is fully idle. Any sticky error
583// surfaces on the next `wait()`.
584
585#[derive(Debug)]
586struct CheckpointJob {
587    snapshot: spg_engine::EngineSnapshot,
588    marker_lsn: u64,
589    db_path: PathBuf,
590    wal_dir: PathBuf,
591    wal: Arc<WalGroup>,
592    /// Snapshot-time view of the cold-tier segment set. Carried into the
593    /// worker so any concurrent `freeze_oldest_to_cold` after the trigger
594    /// rides the *next* checkpoint's manifest — same staleness window
595    /// the sync path already had.
596    cold_segments: Vec<(u32, PathBuf)>,
597    /// Shared with `PersistenceCtx` so the worker's chunk rotation is
598    /// visible to subsequent diag / Drop introspection.
599    current_chunk_path: Arc<Mutex<PathBuf>>,
600}
601
602#[derive(Debug, Default)]
603struct CheckpointState {
604    /// Set by the front when it has a job ready; cleared when the worker
605    /// picks it up.
606    pending: Option<CheckpointJob>,
607    /// True while the worker is mid-execute. `pending.is_some() || inflight`
608    /// defines "busy" for the trigger / wait predicate.
609    inflight: bool,
610    /// Sticky error from the worker's last failure. Cleared when surfaced
611    /// to a `wait()` caller.
612    last_error: Option<EngineError>,
613    /// Drop signal — worker exits after the current job (or immediately if
614    /// idle and no pending).
615    shutdown: bool,
616}
617
618#[derive(Debug)]
619struct CheckpointWorker {
620    state: Arc<(Mutex<CheckpointState>, Condvar)>,
621    handle: Option<JoinHandle<()>>,
622}
623
624impl CheckpointWorker {
625    fn spawn() -> Self {
626        let state: Arc<(Mutex<CheckpointState>, Condvar)> =
627            Arc::new((Mutex::new(CheckpointState::default()), Condvar::new()));
628        let state_for_thread = Arc::clone(&state);
629        let handle = thread::Builder::new()
630            .name("spg-checkpoint".into())
631            .spawn(move || checkpoint_worker_loop(&state_for_thread))
632            .expect("spawn checkpoint worker");
633        Self {
634            state,
635            handle: Some(handle),
636        }
637    }
638
639    /// Try to enqueue a job. Returns `Ok(true)` if the worker accepted it,
640    /// `Ok(false)` if a job was already pending or in flight (skip — the
641    /// next trigger will pick up newer state). Surfaces any sticky error
642    /// from a previous run before considering the new job, so async paths
643    /// can't lose a failure indefinitely.
644    fn try_enqueue(&self, job: CheckpointJob) -> Result<bool, EngineError> {
645        let (lock, cond) = &*self.state;
646        let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
647        if let Some(e) = g.last_error.take() {
648            return Err(e);
649        }
650        if g.pending.is_some() || g.inflight {
651            return Ok(false);
652        }
653        g.pending = Some(job);
654        cond.notify_one();
655        Ok(true)
656    }
657
658    /// Block until the worker is idle (no pending, not in flight). Returns
659    /// any sticky error from the last run; clears it on the way out.
660    fn wait(&self) -> Result<(), EngineError> {
661        let (lock, cond) = &*self.state;
662        let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
663        while g.pending.is_some() || g.inflight {
664            g = cond.wait(g).unwrap_or_else(|e| e.into_inner());
665        }
666        match g.last_error.take() {
667            Some(e) => Err(e),
668            None => Ok(()),
669        }
670    }
671}
672
673impl Drop for CheckpointWorker {
674    fn drop(&mut self) {
675        {
676            let (lock, cond) = &*self.state;
677            let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
678            g.shutdown = true;
679            cond.notify_one();
680        }
681        if let Some(h) = self.handle.take() {
682            let _ = h.join();
683        }
684    }
685}
686
687fn checkpoint_worker_loop(state: &Arc<(Mutex<CheckpointState>, Condvar)>) {
688    let (lock, cond) = &**state;
689    loop {
690        let job = {
691            let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
692            while g.pending.is_none() && !g.shutdown {
693                g = cond.wait(g).unwrap_or_else(|e| e.into_inner());
694            }
695            if g.pending.is_none() {
696                // shutdown with no pending → exit cleanly.
697                return;
698            }
699            // Even on shutdown, drain the pending job first so the Drop-time
700            // final checkpoint is durable before exit.
701            let job = g.pending.take().expect("loop invariant");
702            g.inflight = true;
703            job
704        };
705        let result = execute_checkpoint_job(job);
706        {
707            let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
708            g.inflight = false;
709            if let Err(e) = result {
710                g.last_error = Some(e);
711            }
712            cond.notify_all();
713        }
714    }
715}
716
717fn execute_checkpoint_job(job: CheckpointJob) -> Result<(), EngineError> {
718    // 1. Serialize the captured snapshot. Heavy; this is the whole point
719    //    of CoW — it runs off the engine borrow.
720    let snapshot = job.snapshot.serialize();
721    // 2. Snapshot tmp+rename. Atomic on POSIX; rename implicitly fsyncs
722    //    the data the next directory walk sees.
723    let tmp = {
724        let mut t = job.db_path.clone();
725        let mut name = t
726            .file_name()
727            .map(std::ffi::OsStr::to_os_string)
728            .unwrap_or_default();
729        name.push(".tmp");
730        t.set_file_name(name);
731        t
732    };
733    std::fs::write(&tmp, &snapshot).map_err(io_err)?;
734    std::fs::rename(&tmp, &job.db_path).map_err(io_err)?;
735    // 3. Manifest tmp+rename (cold tier present).
736    if !job.cold_segments.is_empty() {
737        let snap_crc = spg_crypto::crc32::crc32(&snapshot);
738        let entries: Vec<ColdSegmentEntry> = job
739            .cold_segments
740            .iter()
741            .filter_map(|(segment_id, path)| {
742                let bytes = std::fs::read(path).ok()?;
743                Some(ColdSegmentEntry {
744                    segment_id: *segment_id,
745                    path: path.clone(),
746                    crc32: spg_crypto::crc32::crc32(&bytes),
747                })
748            })
749            .collect();
750        let manifest = CatalogManifest {
751            catalog_crc32: snap_crc,
752            cold_segments: entries,
753            wal_baseline_offset: 0,
754        };
755        let m_bytes = manifest.serialize();
756        let m_path = spg_manifest_path(&job.db_path);
757        if let Some(dir) = m_path.parent() {
758            std::fs::create_dir_all(dir).map_err(io_err)?;
759        }
760        let m_tmp = {
761            let mut t = m_path.clone();
762            let mut name = t
763                .file_name()
764                .map(std::ffi::OsStr::to_os_string)
765                .unwrap_or_default();
766            name.push(".tmp");
767            t.set_file_name(name);
768            t
769        };
770        std::fs::write(&m_tmp, &m_bytes).map_err(io_err)?;
771        std::fs::rename(&m_tmp, &m_path).map_err(io_err)?;
772    }
773    // 4. Enqueue the v4 checkpoint marker carrying the captured LSN. The
774    //    WalGroup is thread-safe so a live commit can interleave — the
775    //    marker's LSN, not its position in the chunk, anchors replay.
776    let marker_ts = wall_clock_micros();
777    let marker = encode_v4_checkpoint_marker(job.marker_lsn, marker_ts, &job.db_path);
778    job.wal.enqueue(&marker);
779    job.wal.flush_now()?;
780    // 5. Rotate the active chunk. New commits land in the fresh chunk;
781    //    pre-marker history stays addressable in the old chunk for PITR /
782    //    retention. The shared `current_chunk_path` is updated under its
783    //    own lock before the WalGroup swap so diag readers never see a
784    //    handle that no longer matches the recorded path.
785    let new_chunk_path = job
786        .wal_dir
787        .join(chunk_filename(marker_ts, job.marker_lsn + 1));
788    let new_handle = OpenOptions::new()
789        .create(true)
790        .append(true)
791        .read(true)
792        .open(&new_chunk_path)
793        .map_err(io_err)?;
794    fsync_dir(&job.wal_dir);
795    {
796        let mut p = job
797            .current_chunk_path
798            .lock()
799            .unwrap_or_else(|e| e.into_inner());
800        *p = new_chunk_path;
801    }
802    job.wal.rotate_file(new_handle);
803    Ok(())
804}
805
806impl WalTicket {
807    /// Block until the record this ticket covers is durable.
808    ///
809    /// Under `SPG_SYNCHRONOUS_COMMIT=off` this returns
810    /// immediately — the background flusher (or the next
811    /// checkpoint / clean shutdown) makes the record durable
812    /// within `SPG_WAL_WRITER_DELAY_MS`. Same contract as PG's
813    /// `synchronous_commit = off`.
814    ///
815    /// # Errors
816    /// Surfaces the leader's IO error if the batch flush failed
817    /// (the WAL is then poisoned for all subsequent writes).
818    pub fn wait(&self) -> Result<(), EngineError> {
819        if !synchronous_commit_on() {
820            return Ok(());
821        }
822        self.group.wait_flushed(self.seq)
823    }
824}
825
826/// v7.19 P3 — retention sweep loop. Runs in a dedicated thread
827/// spawned by `Database::open_path` when `SPG_PITR_RETENTION_HOURS`
828/// is set to a non-zero value. Wakes every
829/// `SPG_PITR_RETENTION_CHECK_SEC` (default 60 s), enumerates chunks
830/// under `wal_dir`, archives via `SPG_PITR_ARCHIVE_CMD` if set, and
831/// deletes anything older than `retention_hours`.
832///
833/// Loud-failure posture matches PG's `archive_command`: if the
834/// archive command returns non-zero, the chunk stays on disk and
835/// a warning prints to stderr. The retention sweep doesn't delete
836/// a chunk it failed to archive.
837fn retention_sweep_loop(
838    wal_dir: PathBuf,
839    retention_hours: u64,
840    check_interval: std::time::Duration,
841    archive_cmd: Option<String>,
842    shutdown: Arc<AtomicBool>,
843) {
844    while !shutdown.load(Ordering::SeqCst) {
845        if let Err(e) = retention_sweep_once(&wal_dir, retention_hours, archive_cmd.as_deref()) {
846            eprintln!("spg-embedded: retention sweep error: {e}");
847        }
848        // Sleep in short ticks so shutdown isn't blocked on a
849        // 60 s naptime when Drop signals.
850        let mut elapsed = std::time::Duration::ZERO;
851        let tick = std::time::Duration::from_millis(250);
852        while elapsed < check_interval {
853            if shutdown.load(Ordering::SeqCst) {
854                return;
855            }
856            std::thread::sleep(tick);
857            elapsed += tick;
858        }
859    }
860}
861
862/// v7.19 P3 — one retention sweep pass over `wal_dir`. Extracted
863/// from the loop so tests can drive it directly. Public so the
864/// e2e_pitr_retention integration test (and any future operator
865/// tooling that wants synchronous retention) can call it.
866pub fn retention_sweep_once(
867    wal_dir: &Path,
868    retention_hours: u64,
869    archive_cmd: Option<&str>,
870) -> std::io::Result<()> {
871    if !wal_dir.exists() {
872        return Ok(());
873    }
874    let now_us = wall_clock_micros();
875    let cutoff_us = (now_us as i128 - (retention_hours as i128 * 3_600 * 1_000_000)) as i64;
876    let chunks = sorted_wal_chunks(wal_dir)?;
877    for chunk in chunks {
878        // Don't sweep the most-recent chunk; it's the live one
879        // execute() is appending to. Compare against the largest
880        // filename-prefix unix_us.
881        let stem = match chunk.file_stem().and_then(|s| s.to_str()) {
882            Some(s) => s,
883            None => continue,
884        };
885        let chunk_us: i64 = stem
886            .split_once('_')
887            .and_then(|(prefix, _)| i64::from_str_radix(prefix, 16).ok())
888            .unwrap_or(0);
889        if chunk_us >= cutoff_us {
890            continue;
891        }
892        // Archive first if requested.
893        if let Some(cmd) = archive_cmd {
894            if !cmd.is_empty() {
895                let output = std::process::Command::new("sh")
896                    .arg("-c")
897                    .arg(cmd)
898                    .arg("--")
899                    .arg(&chunk)
900                    .output()?;
901                if !output.status.success() {
902                    eprintln!(
903                        "spg-embedded: SPG_PITR_ARCHIVE_CMD failed for {} (exit {}); chunk stays on disk",
904                        chunk.display(),
905                        output.status.code().unwrap_or(-1)
906                    );
907                    continue;
908                }
909            }
910        }
911        // Delete the chunk + its sibling .checksum if present.
912        if let Err(e) = std::fs::remove_file(&chunk) {
913            eprintln!(
914                "spg-embedded: retention remove {} failed: {e}",
915                chunk.display()
916            );
917            continue;
918        }
919        let mut cs = chunk.clone();
920        let mut name = cs.file_name().map(|n| n.to_os_string()).unwrap_or_default();
921        name.push(".checksum");
922        cs.set_file_name(name);
923        let _ = std::fs::remove_file(&cs);
924    }
925    Ok(())
926}
927
928/// v7.20 — group-commit delay window in µs (PG `commit_delay`
929/// analogue). The flush leader sleeps this long before taking
930/// the batch so concurrent writers pile in. Default 150 µs;
931/// `SPG_COMMIT_DELAY_US=0` disables.
932fn commit_delay_us() -> u64 {
933    static CACHED: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
934    *CACHED.get_or_init(|| {
935        std::env::var("SPG_COMMIT_DELAY_US")
936            .ok()
937            .and_then(|s| s.parse::<u64>().ok())
938            .unwrap_or(150)
939    })
940}
941
942/// v7.20 — PG `synchronous_commit` analogue. `on` (default):
943/// `execute()` blocks until its WAL record is fsynced —
944/// zero-loss durability. `off`: `execute()` returns after the
945/// in-memory mutation + WAL enqueue; a background flusher
946/// thread writes + fsyncs every `SPG_WAL_WRITER_DELAY_MS`
947/// (default 200 ms — PG's `wal_writer_delay` default). Crash
948/// window = up to one flush interval of confirmed-but-unsynced
949/// commits — exactly the trade PG documents for the same
950/// setting. Clean shutdown (Drop / checkpoint) always flushes.
951fn synchronous_commit_on() -> bool {
952    static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
953    *CACHED.get_or_init(|| {
954        !std::env::var("SPG_SYNCHRONOUS_COMMIT")
955            .map(|v| v.eq_ignore_ascii_case("off") || v == "0" || v.eq_ignore_ascii_case("false"))
956            .unwrap_or(false)
957    })
958}
959
960/// v7.20 — background WAL flusher cadence for
961/// `SPG_SYNCHRONOUS_COMMIT=off` (PG `wal_writer_delay`).
962fn wal_writer_delay_ms() -> u64 {
963    static CACHED: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
964    *CACHED.get_or_init(|| {
965        std::env::var("SPG_WAL_WRITER_DELAY_MS")
966            .ok()
967            .and_then(|s| s.parse::<u64>().ok())
968            .filter(|&n| n > 0)
969            .unwrap_or(200)
970    })
971}
972
973fn pitr_retention_hours() -> u64 {
974    std::env::var("SPG_PITR_RETENTION_HOURS")
975        .ok()
976        .and_then(|s| s.parse::<u64>().ok())
977        .unwrap_or(0)
978}
979
980fn pitr_retention_check_sec() -> u64 {
981    std::env::var("SPG_PITR_RETENTION_CHECK_SEC")
982        .ok()
983        .and_then(|s| s.parse::<u64>().ok())
984        .filter(|&n| n > 0)
985        .unwrap_or(60)
986}
987
988fn pitr_archive_cmd() -> Option<String> {
989    std::env::var("SPG_PITR_ARCHIVE_CMD")
990        .ok()
991        .filter(|s| !s.is_empty())
992}
993
994/// v7.19 — replay every record from `wal_bytes` whose
995/// `commit_lsn` is strictly greater than `floor_lsn`. v3 records
996/// (no LSN) and v4 records with `commit_lsn <= floor_lsn` are
997/// skipped — the snapshot loaded ahead of this call already
998/// reflects them, and re-applying would DuplicateTable /
999/// double-insert. v3 records inside the legacy migration chunk
1000/// always apply because the migration sets `floor_lsn = 0` and
1001/// v3 records carry no LSN to compare; the pre-migration
1002/// behaviour (every record replays) is what the migration
1003/// preserves.
1004///
1005/// Returns the count of records successfully applied. Same
1006/// torn-tail semantics as `replay_wal_into_engine`.
1007fn replay_wal_filtered(
1008    wal_bytes: &[u8],
1009    engine: &mut Engine,
1010    floor_lsn: u64,
1011    quarantine: &mut Vec<QuarantinedStmt>,
1012) -> Result<usize, String> {
1013    let records = parse_wal_records(wal_bytes)?;
1014    let total_records = records.len();
1015    let mut applied = 0usize;
1016    // v7.37.8 — periodic heartbeat. Operators / mailrs see in the
1017    // container log that replay is making progress (or, if no line
1018    // appears for 30+ s, that it isn't). The `SPG_REPLAY_HEARTBEAT_MS`
1019    // env var tunes the cadence; 0 disables. Default 5 s — frequent
1020    // enough that mailrs's 15 s pool retries see at least one beat,
1021    // sparse enough not to flood normal startup logs.
1022    let heartbeat_ms = std::env::var("SPG_REPLAY_HEARTBEAT_MS")
1023        .ok()
1024        .and_then(|s| s.parse::<u64>().ok())
1025        .unwrap_or(5_000);
1026    let mut last_beat = std::time::Instant::now();
1027    let replay_started = last_beat;
1028    // v7.37.7 A.1 — per-record-type timing histogram gated on env var.
1029    // Records mailrs prod snapshot's WAL has ~thousands of WAL_V5_ROW_REDO
1030    // entries; v7.37.5 ack claimed batched apply_redo brought replay to
1031    // ~500ms but fresh-extract measurement shows ~250s. This histogram
1032    // splits ROW_REDO vs SQL re-execute time so the fix target is concrete.
1033    let timing = std::env::var_os("SPG_OPEN_PATH_TIMING").is_some();
1034    let mut redo_count = 0u64;
1035    let mut redo_us = 0u128;
1036    let mut sql_count = 0u64;
1037    let mut sql_us = 0u128;
1038    let mut marker_count = 0u64;
1039    let mut skip_count = 0u64;
1040    for r in &records {
1041        // Skip markers + non-SQL records.
1042        if r.type_byte == WAL_V3_TYPE_DURABILITY_CHECKPOINT
1043            || r.type_byte == WAL_V4_TYPE_CHECKPOINT_MARKER
1044        {
1045            marker_count += 1;
1046            continue;
1047        }
1048        // v4 SQL records carry an LSN. Apply iff strictly above
1049        // the snapshot floor.
1050        if r.type_byte == WAL_V4_TYPE_AUTO_COMMIT_SQL
1051            || r.type_byte == WAL_V4_TYPE_TX_COMMIT_SQL
1052            || r.type_byte == WAL_V5_TYPE_ROW_REDO
1053        {
1054            if let Some(lsn) = r.commit_lsn {
1055                if lsn <= floor_lsn {
1056                    skip_count += 1;
1057                    continue;
1058                }
1059            }
1060        }
1061        // v7.34 (crash-recovery P0 #2) — row-level redo record: apply the
1062        // physical changes directly (O(changed rows)) instead of
1063        // re-executing SQL (the O(records × rows) statement-replay that
1064        // hung the mailrs P0). The payload is `encode_redo_log` bytes, not
1065        // SQL, so it never enters the from_utf8 / split_statements path.
1066        if r.type_byte == WAL_V5_TYPE_ROW_REDO {
1067            let t = std::time::Instant::now();
1068            let changes = spg_storage::decode_redo_log(r.sql)
1069                .map_err(|e| format!("redo decode at offset {}: {e:?}", r.offset))?;
1070            engine
1071                .apply_redo(&changes)
1072                .map_err(|e| format!("redo apply at offset {}: {e:?}", r.offset))?;
1073            redo_us += t.elapsed().as_micros();
1074            redo_count += 1;
1075            applied += 1;
1076            // v7.37.8 — emit heartbeat (operator visibility — see
1077            // CHANGELOG v7.37.8).
1078            if heartbeat_ms > 0
1079                && last_beat.elapsed().as_millis() as u64 >= heartbeat_ms
1080            {
1081                eprintln!(
1082                    "[spg replay heartbeat] applied={applied}/{total_records} \
1083                     ({:.1}%, elapsed {:.1}s)",
1084                    100.0 * applied as f64 / total_records.max(1) as f64,
1085                    replay_started.elapsed().as_secs_f64()
1086                );
1087                last_beat = std::time::Instant::now();
1088            }
1089            continue;
1090        }
1091        // v3 records (type 0x01, no LSN) always apply — the
1092        // legacy migration path is the only place they appear,
1093        // and floor_lsn=0 there.
1094        let sql = match std::str::from_utf8(r.sql) {
1095            Ok(s) => s,
1096            Err(e) => return Err(format!("non-UTF-8 SQL at offset {}: {e}", r.offset)),
1097        };
1098        // v7.21 — a tx-commit record carries the whole transaction
1099        // as a `";\n"`-joined script; auto-commit records are a
1100        // single statement, for which split_statements is a no-op.
1101        //
1102        // v7.30.1 (mailrs round-24 ask 2) — a statement the engine
1103        // REJECTS is quarantined, not fatal: "one statement failed
1104        // to replay" ≠ "the catalog is corrupt". Framing damage
1105        // (parse_wal_records / non-UTF-8 above) still errors — that
1106        // IS corruption. Subsequent statements of a tx script keep
1107        // applying: the bricking class is a no-op-at-runtime
1108        // statement that re-applies non-idempotently, and skipping
1109        // just it reconstructs the runtime state.
1110        let t = std::time::Instant::now();
1111        for stmt in split_statements(sql) {
1112            if let Err(e) = engine.execute(stmt) {
1113                quarantine.push(QuarantinedStmt {
1114                    offset: r.offset,
1115                    sql: stmt.to_string(),
1116                    error: format!("{e:?}"),
1117                });
1118            }
1119        }
1120        sql_us += t.elapsed().as_micros();
1121        sql_count += 1;
1122        applied += 1;
1123        // v7.37.8 — emit heartbeat. Duplicated against the V5
1124        // ROW_REDO branch above; kept duplicated rather than
1125        // factored so the hot loop stays readable.
1126        if heartbeat_ms > 0 && last_beat.elapsed().as_millis() as u64 >= heartbeat_ms {
1127            eprintln!(
1128                "[spg replay heartbeat] applied={applied}/{total_records} \
1129                 ({:.1}%, elapsed {:.1}s)",
1130                100.0 * applied as f64 / total_records.max(1) as f64,
1131                replay_started.elapsed().as_secs_f64()
1132            );
1133            last_beat = std::time::Instant::now();
1134        }
1135    }
1136    if timing {
1137        eprintln!(
1138            "[replay_wal_filtered] total_records={} applied={} redo={} ({:.3}s) sql={} ({:.3}s) marker={} skip_lsn={}",
1139            records.len(),
1140            applied,
1141            redo_count,
1142            redo_us as f64 / 1_000_000.0,
1143            sql_count,
1144            sql_us as f64 / 1_000_000.0,
1145            marker_count,
1146            skip_count,
1147        );
1148    }
1149    Ok(applied)
1150}
1151
1152/// v7.30.1 (mailrs round-24 ask 2) — one statement that failed to
1153/// re-apply during boot replay. Kept for forensics in a
1154/// `quarantine-*.log` beside the WAL chunks; the boot continues.
1155struct QuarantinedStmt {
1156    offset: usize,
1157    sql: String,
1158    error: String,
1159}
1160
1161fn format_quarantine_line(q: &QuarantinedStmt) -> String {
1162    format!("offset {}: {}\n  rejected: {}\n", q.offset, q.sql, q.error)
1163}
1164
1165/// v7.19 — WAL chunk filename format. Zero-padded 16-digit
1166/// hex on both parts so default lexicographic sort matches
1167/// numeric order, with the unix_us prefix coming first so
1168/// the on-disk listing is chronological too.
1169/// v7.34 (crash-recovery P0 #2) — fsync a directory so a newly created
1170/// file's entry is durable. `sync_data` on a chunk file persists its
1171/// bytes but NOT the parent directory entry that names it; a power loss
1172/// after creating a fresh WAL chunk could lose that entry and make the
1173/// chunk (and the committed records in it) unreachable on restart.
1174/// Best-effort — a platform that rejects directory fsync is no worse off.
1175fn fsync_dir(dir: &Path) {
1176    if let Ok(f) = File::open(dir) {
1177        let _ = f.sync_all();
1178    }
1179}
1180
1181fn chunk_filename(unix_us: i64, leading_lsn: u64) -> String {
1182    // Negative timestamps shouldn't happen in practice (we sit
1183    // post-1970), but clamp to 0 so the zero-padded
1184    // representation stays sortable.
1185    let us = unix_us.max(0) as u64;
1186    format!("{us:016x}_{leading_lsn:016x}.wal")
1187}
1188
1189/// v7.19 — filename used for the legacy single-file WAL when
1190/// `open_path` migrates a v7.18-layout database into the new
1191/// chunk directory. Lexicographically smallest possible value
1192/// so subsequent chunks sort after it.
1193fn legacy_chunk_filename() -> String {
1194    chunk_filename(0, 0)
1195}
1196
1197/// CoW-4 (v7.34) — D10 fallback: read one cold-segment file and
1198/// hand its bytes to the catalog. The segment binary is self-validating
1199/// (magic + internal CRC32 via `OwnedSegment::from_bytes`), so we don't
1200/// need the manifest's `segment_crc32` to trust it. Returns `true` on a
1201/// successful attach (caller bumps `cold_segment_paths`), `false` on a
1202/// per-segment failure that is logged but doesn't abort boot.
1203fn attach_segment_from_disk(engine: &mut Engine, segment_id: u32, path: &Path) -> bool {
1204    if engine.catalog().cold_segment(segment_id).is_some() {
1205        return true;
1206    }
1207    let bytes = match std::fs::read(path) {
1208        Ok(b) => b,
1209        Err(e) => {
1210            eprintln!(
1211                "spg-embedded: cold-segment scan skip {}: read failed: {e}",
1212                path.display()
1213            );
1214            return false;
1215        }
1216    };
1217    let mut new_cat = engine.catalog().clone();
1218    if let Err(e) = new_cat.load_segment_bytes_at(segment_id, bytes) {
1219        eprintln!(
1220            "spg-embedded: cold-segment scan skip {}: parse/load failed: {e}",
1221            path.display()
1222        );
1223        return false;
1224    }
1225    engine.replace_catalog(new_cat);
1226    true
1227}
1228
1229/// CoW-4 (v7.34) — D10 + missing-manifest fallback: scan
1230/// `<db>.spg/segments/` for `seg_<id>.spg` files and attach any that
1231/// aren't already in `cold_segment_paths`. Closes the window where a
1232/// crash between snapshot rename and manifest rename leaves
1233/// post-checkpoint cold segments orphaned on disk (the snapshot's CRC
1234/// no longer matches the stale manifest, so the manifest path
1235/// silently dropped them). The segment parser self-verifies, so a
1236/// torn write surfaces as a per-segment skip, never silent corruption.
1237fn scan_cold_segments_dir(
1238    segments_dir: &Path,
1239    engine: &mut Engine,
1240    cold_segment_paths: &mut BTreeMap<u32, PathBuf>,
1241) {
1242    // v7.34.1 (mailrs prod report bug A): single-file catalogs (e.g.
1243    // `/data/spg/mailrs.spg` is a regular file, not the `<db>/<db>.spg`
1244    // layout this scan assumes) make the computed `<db>.spg/segments`
1245    // path traverse a file inode, which surfaces as ENOTDIR (`Not a
1246    // directory`, errno 20). Treat any non-directory state — absent,
1247    // file-in-the-way, stat-blocked — as "no segments to scan" and
1248    // silently return. The eprintln below only fires for the genuine
1249    // mid-walk read errors (permission flip, IO failure) that operators
1250    // need to see.
1251    if !segments_dir.is_dir() {
1252        return;
1253    }
1254    let read_dir = match std::fs::read_dir(segments_dir) {
1255        Ok(rd) => rd,
1256        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
1257        Err(e) => {
1258            eprintln!(
1259                "spg-embedded: cold-segment scan: cannot read {}: {e}",
1260                segments_dir.display()
1261            );
1262            return;
1263        }
1264    };
1265    for entry in read_dir.flatten() {
1266        let path = entry.path();
1267        // Only the canonical `seg_<id>.spg` form. `.tmp` half-renames
1268        // and unknown extensions are skipped — the segment writer's
1269        // tmp+rename pattern guarantees `.spg` files are either fully
1270        // written or absent.
1271        if path.extension().and_then(|s| s.to_str()) != Some("spg") {
1272            continue;
1273        }
1274        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
1275            continue;
1276        };
1277        let Some(id_str) = stem.strip_prefix("seg_") else {
1278            continue;
1279        };
1280        let Ok(segment_id) = id_str.parse::<u32>() else {
1281            continue;
1282        };
1283        if cold_segment_paths.contains_key(&segment_id) {
1284            continue;
1285        }
1286        if attach_segment_from_disk(engine, segment_id, &path) {
1287            cold_segment_paths.insert(segment_id, path);
1288        }
1289    }
1290}
1291
1292/// v7.19 — list every `.wal` file in `wal_dir` in
1293/// lexicographic order (which doubles as chunk-creation
1294/// order thanks to the zero-padded filename format).
1295fn sorted_wal_chunks(wal_dir: &Path) -> std::io::Result<Vec<PathBuf>> {
1296    let mut paths = Vec::new();
1297    let read_dir = match std::fs::read_dir(wal_dir) {
1298        Ok(rd) => rd,
1299        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(paths),
1300        Err(e) => return Err(e),
1301    };
1302    for entry in read_dir {
1303        let entry = entry?;
1304        let path = entry.path();
1305        if path.extension().and_then(|s| s.to_str()) == Some("wal") {
1306            paths.push(path);
1307        }
1308    }
1309    paths.sort();
1310    Ok(paths)
1311}
1312
1313/// v7.18 PITR — encode one v4 `checkpoint_marker` record. Layout:
1314///
1315/// ```text
1316/// [u32 LE (payload_len | WAL_V2_SENTINEL | WAL_V3_FLAG)]
1317/// [u32 LE crc32 over (type_byte || payload)]
1318/// [u8  type = 0x11]
1319/// payload:
1320///   [u64 LE checkpoint_lsn]
1321///   [i64 LE checkpoint_unix_us  (WAL_V4_NO_CLOCK if no clock)]
1322///   [u16 LE snapshot_path_len]
1323///   [snapshot_path_bytes]
1324/// ```
1325///
1326/// `payload_len` covers only the payload — keeping the framing
1327/// uniform across v3 / v4 record types so torn-write detection in
1328/// `replay_wal_into_engine` stays trivial.
1329fn encode_v4_checkpoint_marker(
1330    checkpoint_lsn: u64,
1331    checkpoint_unix_us: i64,
1332    snapshot_path: &Path,
1333) -> Vec<u8> {
1334    let snapshot_bytes = snapshot_path.to_string_lossy().into_owned();
1335    let snap_payload = snapshot_bytes.as_bytes();
1336    let snap_len_u16: u16 = snap_payload.len().min(u16::MAX as usize) as u16;
1337    let mut payload = Vec::with_capacity(8 + 8 + 2 + snap_payload.len());
1338    payload.extend_from_slice(&checkpoint_lsn.to_le_bytes());
1339    payload.extend_from_slice(&checkpoint_unix_us.to_le_bytes());
1340    payload.extend_from_slice(&snap_len_u16.to_le_bytes());
1341    payload.extend_from_slice(&snap_payload[..snap_len_u16 as usize]);
1342    let mut crc_buf = Vec::with_capacity(1 + payload.len());
1343    crc_buf.push(WAL_V4_TYPE_CHECKPOINT_MARKER);
1344    crc_buf.extend_from_slice(&payload);
1345    let crc = spg_crypto::crc32::crc32(&crc_buf);
1346    let header = ((payload.len() as u32) | WAL_V2_SENTINEL | WAL_V3_FLAG).to_le_bytes();
1347    let mut out = Vec::with_capacity(4 + 4 + 1 + payload.len());
1348    out.extend_from_slice(&header);
1349    out.extend_from_slice(&crc.to_le_bytes());
1350    out.push(WAL_V4_TYPE_CHECKPOINT_MARKER);
1351    out.extend_from_slice(&payload);
1352    out
1353}
1354
1355/// v7.18 PITR — encode one v4 `auto_commit_sql` record. Layout:
1356///
1357/// ```text
1358/// [u32 LE (sql_len | WAL_V2_SENTINEL | WAL_V3_FLAG)]
1359/// [u32 LE crc32 over (type_byte || lsn || ts || sql_bytes)]
1360/// [u8  type = 0x10]
1361/// [u64 LE commit_lsn]
1362/// [i64 LE commit_unix_us  (= WAL_V4_NO_CLOCK when no ClockFn)]
1363/// [sql bytes]
1364/// ```
1365///
1366/// `sql_len` field stays the SQL byte count — same shape as v3 — so
1367/// replay-buffer torn-write detection compares against
1368/// `WAL_V4_EXTRA_HEADER + sql_len`. v3 records (type 0x01) stay
1369/// readable by the same loop with their original 9-byte header
1370/// arithmetic.
1371fn encode_v4_auto_commit(sql: &str, commit_lsn: u64, commit_unix_us: i64) -> Vec<u8> {
1372    encode_v4_framed(
1373        WAL_V4_TYPE_AUTO_COMMIT_SQL,
1374        sql.as_bytes(),
1375        commit_lsn,
1376        commit_unix_us,
1377    )
1378}
1379
1380/// v7.21 — same envelope, `WAL_V4_TYPE_TX_COMMIT_SQL` type byte.
1381/// `script` = the transaction's statements joined with `";\n"`.
1382fn encode_v4_tx_commit(script: &str, commit_lsn: u64, commit_unix_us: i64) -> Vec<u8> {
1383    encode_v4_framed(
1384        WAL_V4_TYPE_TX_COMMIT_SQL,
1385        script.as_bytes(),
1386        commit_lsn,
1387        commit_unix_us,
1388    )
1389}
1390
1391/// v7.34 (crash-recovery P0 #2) — encode one row-level redo record. Same
1392/// v4 envelope + CRC, type byte 0x13; the payload is the
1393/// `encode_redo_log` bytes (physical changes) instead of SQL text, so
1394/// replay applies them in place of re-executing the statement.
1395fn encode_v5_row_redo(redo_bytes: &[u8], commit_lsn: u64, commit_unix_us: i64) -> Vec<u8> {
1396    encode_v4_framed(WAL_V5_TYPE_ROW_REDO, redo_bytes, commit_lsn, commit_unix_us)
1397}
1398
1399fn encode_v4_framed(
1400    type_byte: u8,
1401    payload: &[u8],
1402    commit_lsn: u64,
1403    commit_unix_us: i64,
1404) -> Vec<u8> {
1405    let mut crc_buf = Vec::with_capacity(1 + WAL_V4_EXTRA_HEADER + payload.len());
1406    crc_buf.push(type_byte);
1407    crc_buf.extend_from_slice(&commit_lsn.to_le_bytes());
1408    crc_buf.extend_from_slice(&commit_unix_us.to_le_bytes());
1409    crc_buf.extend_from_slice(payload);
1410    let crc = spg_crypto::crc32::crc32(&crc_buf);
1411    let header = ((payload.len() as u32) | WAL_V2_SENTINEL | WAL_V3_FLAG).to_le_bytes();
1412    let mut out = Vec::with_capacity(4 + 4 + 1 + WAL_V4_EXTRA_HEADER + payload.len());
1413    out.extend_from_slice(&header);
1414    out.extend_from_slice(&crc.to_le_bytes());
1415    out.push(type_byte);
1416    out.extend_from_slice(&commit_lsn.to_le_bytes());
1417    out.extend_from_slice(&commit_unix_us.to_le_bytes());
1418    out.extend_from_slice(payload);
1419    out
1420}
1421
1422/// v7.1 — decode + apply every record in `wal_bytes` to `engine`.
1423/// Returns the count of records successfully applied. A truncated
1424/// trailing record (mid-write torn) is dropped silently — the
1425/// same recovery story `spg-server`'s boot path uses.
1426fn replay_wal_into_engine(wal_bytes: &[u8], engine: &mut Engine) -> Result<usize, String> {
1427    let mut applied = 0usize;
1428    let mut cur = 0usize;
1429    while cur < wal_bytes.len() {
1430        if wal_bytes.len() - cur < 4 {
1431            // Trailing partial header — torn write, drop and stop.
1432            break;
1433        }
1434        let raw_len = u32::from_le_bytes(wal_bytes[cur..cur + 4].try_into().unwrap());
1435        let is_v2 = raw_len & WAL_V2_SENTINEL != 0;
1436        let is_v3 = is_v2 && (raw_len & WAL_V3_FLAG != 0);
1437        let len_mask = if is_v3 {
1438            !(WAL_V2_SENTINEL | WAL_V3_FLAG)
1439        } else {
1440            !WAL_V2_SENTINEL
1441        };
1442        let rec_len = (raw_len & len_mask) as usize;
1443        let header_len = if is_v3 {
1444            9
1445        } else if is_v2 {
1446            8
1447        } else {
1448            4
1449        };
1450        if wal_bytes.len() - cur < header_len + rec_len {
1451            // Torn record at the tail — drop, stop.
1452            break;
1453        }
1454        if is_v3 {
1455            let type_byte = wal_bytes[cur + 8];
1456            match type_byte {
1457                WAL_V3_TYPE_AUTO_COMMIT_SQL => {}
1458                WAL_V3_TYPE_DURABILITY_CHECKPOINT => {
1459                    // durability_checkpoint marker — skip, no SQL.
1460                    cur += header_len + rec_len;
1461                    continue;
1462                }
1463                WAL_V4_TYPE_CHECKPOINT_MARKER => {
1464                    // v7.18 PITR — checkpoint anchor, skip on replay
1465                    // (engine state past this point reflects the
1466                    // matching snapshot already loaded by the caller).
1467                    cur += header_len + rec_len;
1468                    continue;
1469                }
1470                WAL_V4_TYPE_AUTO_COMMIT_SQL | WAL_V4_TYPE_TX_COMMIT_SQL => {
1471                    // v7.18 PITR — v4 record carries 16 bytes of
1472                    // (commit_lsn, commit_unix_us) between the type
1473                    // byte and the SQL payload. Replay reads them but
1474                    // does not enforce them — the engine doesn't
1475                    // surface LSN/clock here. Restore tooling
1476                    // (spgctl) parses them via parse_wal_record below.
1477                    //
1478                    // v7.21 — tx-commit records (0x12) carry a whole
1479                    // transaction as a `";\n"`-joined script;
1480                    // split_statements is a no-op on the single-
1481                    // statement auto-commit form.
1482                    let v4_total = header_len + WAL_V4_EXTRA_HEADER + rec_len;
1483                    if wal_bytes.len() - cur < v4_total {
1484                        // Torn v4 record at the tail — drop, stop.
1485                        break;
1486                    }
1487                    let sql_start = cur + header_len + WAL_V4_EXTRA_HEADER;
1488                    let sql_bytes = &wal_bytes[sql_start..sql_start + rec_len];
1489                    let sql = std::str::from_utf8(sql_bytes)
1490                        .map_err(|e| format!("WAL replay: non-UTF-8 SQL at offset {cur}: {e}"))?;
1491                    for stmt in split_statements(sql) {
1492                        engine.execute(stmt).map_err(|e| {
1493                            format!("WAL replay: apply {stmt:?} at offset {cur} rejected: {e:?}")
1494                        })?;
1495                    }
1496                    applied += 1;
1497                    cur += v4_total;
1498                    continue;
1499                }
1500                other => {
1501                    return Err(format!(
1502                        "WAL replay: unknown v3 type byte {other:#04x} at offset {cur}"
1503                    ));
1504                }
1505            }
1506        }
1507        let sql_bytes = &wal_bytes[cur + header_len..cur + header_len + rec_len];
1508        let sql = std::str::from_utf8(sql_bytes)
1509            .map_err(|e| format!("WAL replay: non-UTF-8 SQL at offset {cur}: {e}"))?;
1510        engine
1511            .execute(sql)
1512            .map_err(|e| format!("WAL replay: apply {sql:?} at offset {cur} rejected: {e:?}"))?;
1513        applied += 1;
1514        cur += header_len + rec_len;
1515    }
1516    Ok(applied)
1517}
1518
1519/// v7.18 PITR — parsed WAL record, surfaced for restore / verify
1520/// tooling. The replay loop above doesn't expose LSN/timestamp;
1521/// `spgctl restore --to <timestamp>` and `spgctl verify` need them.
1522/// Returned offsets are byte-positions inside the WAL buffer.
1523#[derive(Debug, Clone)]
1524pub struct WalRecord<'a> {
1525    /// Byte offset in the WAL buffer where this record starts.
1526    pub offset: usize,
1527    /// Type byte (0x01 = v3 auto-commit, 0x10 = v4 auto-commit,
1528    /// 0x02 = durability checkpoint marker).
1529    pub type_byte: u8,
1530    /// `Some(lsn)` for v4 records, `None` for v3.
1531    pub commit_lsn: Option<u64>,
1532    /// `Some(unix_us)` for v4 records carrying a clock-set timestamp,
1533    /// `None` for v3 or for v4 records explicitly written with
1534    /// `WAL_V4_NO_CLOCK` (sentinel for "no ClockFn at commit time").
1535    pub commit_unix_us: Option<i64>,
1536    /// SQL payload as borrowed bytes. Empty for durability markers.
1537    pub sql: &'a [u8],
1538}
1539
1540/// v7.18 PITR — iterate over `wal_bytes` yielding one `WalRecord`
1541/// per intact record. Torn-tail records terminate iteration
1542/// silently (same recovery story as `replay_wal_into_engine`).
1543/// Unknown type bytes inside a v3 envelope return `Err` so the
1544/// caller knows the WAL was written by a newer SPG.
1545pub fn parse_wal_records(wal_bytes: &[u8]) -> Result<Vec<WalRecord<'_>>, String> {
1546    let mut out = Vec::new();
1547    let mut cur = 0usize;
1548    while cur < wal_bytes.len() {
1549        if wal_bytes.len() - cur < 4 {
1550            break;
1551        }
1552        let raw_len = u32::from_le_bytes(wal_bytes[cur..cur + 4].try_into().unwrap());
1553        let is_v2 = raw_len & WAL_V2_SENTINEL != 0;
1554        let is_v3 = is_v2 && (raw_len & WAL_V3_FLAG != 0);
1555        let len_mask = if is_v3 {
1556            !(WAL_V2_SENTINEL | WAL_V3_FLAG)
1557        } else {
1558            !WAL_V2_SENTINEL
1559        };
1560        let rec_len = (raw_len & len_mask) as usize;
1561        let header_len = if is_v3 {
1562            9
1563        } else if is_v2 {
1564            8
1565        } else {
1566            4
1567        };
1568        if wal_bytes.len() - cur < header_len + rec_len {
1569            break;
1570        }
1571        if !is_v3 {
1572            // v1 / v2 records carry no type byte; treat as legacy
1573            // auto-commit SQL with no LSN/time.
1574            let sql = &wal_bytes[cur + header_len..cur + header_len + rec_len];
1575            out.push(WalRecord {
1576                offset: cur,
1577                type_byte: WAL_V3_TYPE_AUTO_COMMIT_SQL,
1578                commit_lsn: None,
1579                commit_unix_us: None,
1580                sql,
1581            });
1582            cur += header_len + rec_len;
1583            continue;
1584        }
1585        let type_byte = wal_bytes[cur + 8];
1586        match type_byte {
1587            WAL_V3_TYPE_AUTO_COMMIT_SQL => {
1588                let sql = &wal_bytes[cur + header_len..cur + header_len + rec_len];
1589                out.push(WalRecord {
1590                    offset: cur,
1591                    type_byte,
1592                    commit_lsn: None,
1593                    commit_unix_us: None,
1594                    sql,
1595                });
1596                cur += header_len + rec_len;
1597            }
1598            WAL_V3_TYPE_DURABILITY_CHECKPOINT => {
1599                out.push(WalRecord {
1600                    offset: cur,
1601                    type_byte,
1602                    commit_lsn: None,
1603                    commit_unix_us: None,
1604                    sql: &[],
1605                });
1606                cur += header_len + rec_len;
1607            }
1608            WAL_V4_TYPE_CHECKPOINT_MARKER => {
1609                // v7.18 PITR — payload = (lsn u64)(ts i64)(path_len u16)(path bytes).
1610                // We surface lsn + ts on the WalRecord; the path lives
1611                // in `sql` since the type byte already disambiguates
1612                // record meaning and adding a dedicated field would
1613                // bloat the iterator return type for every variant.
1614                if rec_len < 18 {
1615                    return Err(format!(
1616                        "WAL parse: checkpoint marker at offset {cur} too short ({rec_len} bytes)"
1617                    ));
1618                }
1619                let lsn = u64::from_le_bytes(
1620                    wal_bytes[cur + header_len..cur + header_len + 8]
1621                        .try_into()
1622                        .unwrap(),
1623                );
1624                let ts_raw = i64::from_le_bytes(
1625                    wal_bytes[cur + header_len + 8..cur + header_len + 16]
1626                        .try_into()
1627                        .unwrap(),
1628                );
1629                let path_len = u16::from_le_bytes(
1630                    wal_bytes[cur + header_len + 16..cur + header_len + 18]
1631                        .try_into()
1632                        .unwrap(),
1633                ) as usize;
1634                if rec_len < 18 + path_len {
1635                    return Err(format!(
1636                        "WAL parse: checkpoint marker at offset {cur} truncated path"
1637                    ));
1638                }
1639                let path_start = cur + header_len + 18;
1640                let path_bytes = &wal_bytes[path_start..path_start + path_len];
1641                let commit_unix_us = if ts_raw == WAL_V4_NO_CLOCK {
1642                    None
1643                } else {
1644                    Some(ts_raw)
1645                };
1646                out.push(WalRecord {
1647                    offset: cur,
1648                    type_byte,
1649                    commit_lsn: Some(lsn),
1650                    commit_unix_us,
1651                    sql: path_bytes,
1652                });
1653                cur += header_len + rec_len;
1654            }
1655            WAL_V4_TYPE_AUTO_COMMIT_SQL | WAL_V4_TYPE_TX_COMMIT_SQL | WAL_V5_TYPE_ROW_REDO => {
1656                let v4_total = header_len + WAL_V4_EXTRA_HEADER + rec_len;
1657                if wal_bytes.len() - cur < v4_total {
1658                    break;
1659                }
1660                let lsn = u64::from_le_bytes(
1661                    wal_bytes[cur + header_len..cur + header_len + 8]
1662                        .try_into()
1663                        .unwrap(),
1664                );
1665                let ts_raw = i64::from_le_bytes(
1666                    wal_bytes[cur + header_len + 8..cur + header_len + 16]
1667                        .try_into()
1668                        .unwrap(),
1669                );
1670                let commit_unix_us = if ts_raw == WAL_V4_NO_CLOCK {
1671                    None
1672                } else {
1673                    Some(ts_raw)
1674                };
1675                let sql_start = cur + header_len + WAL_V4_EXTRA_HEADER;
1676                let sql = &wal_bytes[sql_start..sql_start + rec_len];
1677                out.push(WalRecord {
1678                    offset: cur,
1679                    type_byte,
1680                    commit_lsn: Some(lsn),
1681                    commit_unix_us,
1682                    sql,
1683                });
1684                cur += v4_total;
1685            }
1686            other => {
1687                return Err(format!(
1688                    "WAL parse: unknown type byte {other:#04x} at offset {cur}"
1689                ));
1690            }
1691        }
1692    }
1693    Ok(out)
1694}
1695
1696/// v7.1 — predicate for "should the next `execute()` mutate the
1697/// WAL?" Returns `false` for SELECT / SHOW / EXPLAIN / BEGIN /
1698/// COMMIT / ROLLBACK and the SPG-specific verbs that don't go
1699/// through the auto-commit record path on the server (CHECKPOINT,
1700/// COMPACT). Conservative: anything we don't explicitly know is
1701/// read-only falls through to "write a WAL record".
1702fn sql_is_read_only(sql: &str) -> bool {
1703    let t = sql.trim_start();
1704    let head = t
1705        .split(|c: char| c.is_whitespace() || c == ';' || c == '(')
1706        .next()
1707        .unwrap_or("");
1708    matches!(
1709        head.to_ascii_lowercase().as_str(),
1710        "select"
1711            | "show"
1712            | "explain"
1713            | "begin"
1714            | "commit"
1715            | "rollback"
1716            | "checkpoint"
1717            | "compact"
1718            | "wait"
1719            | "with"
1720    )
1721}
1722
1723/// Embedded SPG database handle. Owns an `Engine` + provides
1724/// ergonomic wrappers around `execute` and `query`. Drops the
1725/// engine on `Drop` — no WAL flush / fsync, because v6.10.3
1726/// is in-memory only.
1727#[derive(Debug)]
1728pub struct Database {
1729    engine: Engine,
1730    /// v7.1 — persistence sidecar. When `Some(p)`, every
1731    /// `execute(sql)` that mutates state appends a v4
1732    /// `auto_commit_sql` WAL record + fsyncs before the call
1733    /// returns; `Drop` writes a final catalog snapshot to
1734    /// `<db_path>` so the next session boots from a clean
1735    /// snapshot + an empty WAL. `None` = in-memory only (the
1736    /// v6.10.3 shape).
1737    persistence: Option<PersistenceCtx>,
1738    /// v7.18 PITR — monotonic per-database commit LSN. Increments
1739    /// before each successful WAL append; bootstrapped at
1740    /// open_path from `max(parse_wal_records → commit_lsn)` so
1741    /// reopen never reuses an LSN. In-memory databases start at
1742    /// 0 and never advance (no WAL = no LSN-meaningful records).
1743    commit_lsn: AtomicU64,
1744    /// v7.21 (round-12 polish) — explicit-transaction WAL buffer.
1745    /// `Some` between an engine-accepted BEGIN and its
1746    /// COMMIT / ROLLBACK on a persistent database. In-transaction
1747    /// mutations only touch the engine's shadow catalog and report
1748    /// `modified_catalog: false`, so the per-statement auto-commit
1749    /// append never fires for them; their bind-final SQL collects
1750    /// here instead and COMMIT flushes the lot as ONE atomic
1751    /// `WAL_V4_TYPE_TX_COMMIT_SQL` record (ROLLBACK just drops it).
1752    /// Always `None` for in-memory databases.
1753    tx_wal: Option<TxWalBuffer>,
1754}
1755
1756/// See [`Database::tx_wal`].
1757#[derive(Debug, Default)]
1758struct TxWalBuffer {
1759    /// Bind-final SQL of every non-read-only statement the engine
1760    /// accepted inside the open transaction, in execution order.
1761    statements: Vec<String>,
1762    /// `(savepoint_name, statements.len() at SAVEPOINT time)` —
1763    /// `ROLLBACK TO SAVEPOINT` truncates `statements` back to the
1764    /// recorded mark so the WAL record matches what the engine
1765    /// keeps. PG name-reuse semantics (latest wins).
1766    savepoints: Vec<(String, usize)>,
1767}
1768
1769/// Statement-level transaction-control classification for the WAL
1770/// buffer. Runs AFTER the engine accepted the statement, so the
1771/// engine stays the single validator — this only mirrors state.
1772enum TxControl {
1773    Begin,
1774    Commit,
1775    Rollback,
1776    RollbackToSavepoint(String),
1777    Savepoint(String),
1778    ReleaseSavepoint,
1779}
1780
1781fn tx_control_kind(sql: &str) -> Option<TxControl> {
1782    let mut words = sql
1783        .split(|c: char| c.is_whitespace() || c == ';')
1784        .filter(|w| !w.is_empty())
1785        .map(str::to_ascii_lowercase);
1786    let head = words.next()?;
1787    match head.as_str() {
1788        "begin" | "start" => Some(TxControl::Begin),
1789        "commit" | "end" => Some(TxControl::Commit),
1790        "savepoint" => words.next().map(TxControl::Savepoint),
1791        "release" => Some(TxControl::ReleaseSavepoint),
1792        "rollback" => match words.next().as_deref() {
1793            // ROLLBACK TO [SAVEPOINT] <name>
1794            Some("to") => {
1795                let next = words.next()?;
1796                let name = if next == "savepoint" {
1797                    words.next()?
1798                } else {
1799                    next
1800                };
1801                Some(TxControl::RollbackToSavepoint(name))
1802            }
1803            _ => Some(TxControl::Rollback),
1804        },
1805        _ => None,
1806    }
1807}
1808
1809#[derive(Debug)]
1810#[allow(dead_code)] // `wal_dir`/`current_chunk_path` are read at boot; kept for Drop/diag introspection.
1811struct PersistenceCtx {
1812    db_path: PathBuf,
1813    /// v7.19 — WAL chunk directory at `<db_path>.wal/`.
1814    /// Replaces the v7.18 single-file `<db_path>.wal` layout.
1815    /// Each chunk file inside is named
1816    /// `<unix_us>_<leading_lsn>.wal` (zero-padded to 16 digits
1817    /// so default-lex sort = LSN order).
1818    wal_dir: PathBuf,
1819    /// Path of the currently-open chunk file inside `wal_dir`.
1820    /// Rotated at checkpoint and whenever the chunk crosses
1821    /// `checkpoint_threshold_bytes`. CoW-2 (v7.34) wraps it in
1822    /// `Arc<Mutex<…>>` because the background-checkpoint worker
1823    /// performs the rotation; this struct keeps a clone so Drop /
1824    /// diag introspection still see the live path.
1825    current_chunk_path: Arc<Mutex<PathBuf>>,
1826    /// v7.19 P3 — retention sweeper handle. `Some` when
1827    /// `SPG_PITR_RETENTION_HOURS > 0` at open_path time; `None`
1828    /// when retention is disabled (the default; v7.18 behaviour
1829    /// preserved). The thread polls `wal_dir` every
1830    /// `SPG_PITR_RETENTION_CHECK_SEC` seconds, archives via
1831    /// `SPG_PITR_ARCHIVE_CMD` if set, then deletes chunks older
1832    /// than the retention window. Signalled to exit via
1833    /// `retention_shutdown` on Drop.
1834    retention_shutdown: Option<Arc<AtomicBool>>,
1835    retention_thread: Option<std::thread::JoinHandle<()>>,
1836    /// v7.20 — background WAL flusher for
1837    /// `SPG_SYNCHRONOUS_COMMIT=off`. `None` in the default
1838    /// synchronous mode. Flushes the pending batch every
1839    /// `SPG_WAL_WRITER_DELAY_MS`; signalled + joined on Drop
1840    /// before the final checkpoint so clean shutdown never
1841    /// loses confirmed commits.
1842    flusher_shutdown: Option<Arc<AtomicBool>>,
1843    flusher_thread: Option<std::thread::JoinHandle<()>>,
1844    /// v7.20 P2 — group-commit WAL. Shared with WalTickets
1845    /// returned by the buffered write path so `wait()` can run
1846    /// after the engine write lock is released.
1847    wal: Arc<WalGroup>,
1848    checkpoint_threshold_bytes: u64,
1849    /// v7.1.4 — `<db_path>.spg/segments/` directory. Cold-tier
1850    /// segments produced by `freeze_oldest_to_cold` / compaction
1851    /// are persisted here as `seg_<id>.spg` files; the manifest
1852    /// at `<db_path>.spg/manifest.v10` records every active
1853    /// segment + its CRC32 so the next boot can verify + reload.
1854    cold_segments_dir: PathBuf,
1855    cold_segment_paths: BTreeMap<u32, PathBuf>,
1856    /// v7.17.0 Phase 6.2 — cross-process exclusion lock. Acquired
1857    /// via `fs::create_dir` on `<db_path>.lock` at open_path
1858    /// entry; released on Drop by `fs::remove_dir`. atomic on
1859    /// every supported platform. A second process opening the
1860    /// same path while the first is still alive hits the
1861    /// create_dir failure and returns
1862    /// `EngineError::Unsupported("database is locked by another
1863    /// process: …")`. Stale locks (process crashed mid-session)
1864    /// must be cleared via `Database::force_unlock(path)` —
1865    /// SPG can't safely fingerprint who owned a stale directory
1866    /// without a libc dep, which would violate spg-embedded's
1867    /// zero-deps charter.
1868    lock_path: PathBuf,
1869    /// v7.37.5 (mailrs crash-recovery Ask 1) — in-process registry
1870    /// guard. Drops alongside the rest of the Database, which
1871    /// de-registers `lock_path` from `ACTIVE_OPEN_PATHS`. Carried
1872    /// here so its lifetime exactly matches the live Database
1873    /// handle; a concurrent sibling open_path in the same process
1874    /// refuses honestly while this guard exists.
1875    lock_registry_guard: LockRegistryGuard,
1876    /// CoW-2 (v7.34) — background-checkpoint worker. `None` only
1877    /// transiently inside `Drop` after the worker has been signalled
1878    /// and joined. The worker carries Arc clones of `wal` and
1879    /// `current_chunk_path`, so it can rotate the active chunk and
1880    /// reflect the new path back here even after the front-end has
1881    /// returned to the caller.
1882    checkpoint_worker: Option<CheckpointWorker>,
1883}
1884
1885impl Database {
1886    /// Open a fresh in-memory database. No WAL, no catalog
1887    /// snapshot on disk — perfect for tests + short-lived
1888    /// CLI tools.
1889    #[must_use]
1890    pub fn open_in_memory() -> Self {
1891        Self {
1892            engine: engine_with_query_byte_budget(Engine::new().with_clock(wall_clock_micros)),
1893            persistence: None,
1894            commit_lsn: AtomicU64::new(0),
1895            tx_wal: None,
1896        }
1897    }
1898
1899    /// v7.1 — Open or create a persistent database backed by
1900    /// the file at `db_path`. The WAL lives at `db_path` +
1901    /// ".wal" (e.g. `./data/spg.db` → `./data/spg.db.wal`). Boot
1902    /// path:
1903    ///
1904    /// 1. If `db_path` exists, restore the catalog snapshot.
1905    /// 2. If the WAL exists, replay every record into the
1906    ///    restored engine — the same recovery story
1907    ///    `spg-server` uses.
1908    /// 3. Open the WAL in append+sync mode so subsequent
1909    ///    `execute()` writes durably commit (one fsync per
1910    ///    mutation).
1911    ///
1912    /// `Drop` writes a final catalog snapshot + truncates the
1913    /// WAL — operators that need a sync barrier at a specific
1914    /// point use `checkpoint()` explicitly.
1915    pub fn open_path(db_path: impl AsRef<Path>) -> Result<Self, EngineError> {
1916        // v7.37.7 A.1 — per-stage timing gated on env var SPG_OPEN_PATH_TIMING.
1917        // v7.37.5 ack reported `open_path 27min→646ms` after the WAL-replay
1918        // fix, but fresh-tarball benchmarks showed ~250 s — strong evidence
1919        // the ack number was on a warm OS page cache. These prints surface
1920        // where the time actually goes per Database::open_path call. Zero
1921        // cost when env unset (one syscall + branch per stage).
1922        let timing = std::env::var_os("SPG_OPEN_PATH_TIMING").is_some();
1923        let timing_start = std::time::Instant::now();
1924        let mut last_stage = timing_start;
1925        let mut stage = |name: &str, last: &mut std::time::Instant| {
1926            if timing {
1927                let now = std::time::Instant::now();
1928                eprintln!(
1929                    "[open_path/{name}] +{:.3}s (total {:.3}s)",
1930                    now.duration_since(*last).as_secs_f64(),
1931                    now.duration_since(timing_start).as_secs_f64()
1932                );
1933                *last = now;
1934            }
1935        };
1936        let db_path = db_path.as_ref().to_path_buf();
1937        stage("entry", &mut last_stage);
1938        // v7.19 — WAL is a directory of chunk files. Legacy
1939        // single-file path stays variable-named `wal_path` for
1940        // the backward-compat migration block below.
1941        let wal_path = {
1942            let mut p = db_path.clone();
1943            let name = p
1944                .file_name()
1945                .map(|n| {
1946                    let mut s = n.to_os_string();
1947                    s.push(".wal");
1948                    s
1949                })
1950                .unwrap_or_else(|| std::ffi::OsString::from(".wal"));
1951            p.set_file_name(name);
1952            p
1953        };
1954        let wal_dir = wal_path.clone();
1955        if let Some(parent) = db_path.parent()
1956            && !parent.as_os_str().is_empty()
1957        {
1958            std::fs::create_dir_all(parent).map_err(io_err)?;
1959        }
1960        // v7.17.0 Phase 6.2 — acquire cross-process exclusion
1961        // lock before touching any catalog / WAL bytes. atomic
1962        // mkdir on every supported platform; a second process
1963        // opening the same path while the first is still alive
1964        // hits the create_dir failure and gets a clear error.
1965        let lock_path = {
1966            let mut p = db_path.clone();
1967            let name = p
1968                .file_name()
1969                .map(|n| {
1970                    let mut s = n.to_os_string();
1971                    s.push(".lock");
1972                    s
1973                })
1974                .unwrap_or_else(|| std::ffi::OsString::from(".lock"));
1975            p.set_file_name(name);
1976            p
1977        };
1978        // v7.37.5 (mailrs crash-recovery Ask 1) — register the
1979        // lock_path in the in-process registry FIRST. Drop on this
1980        // guard de-registers automatically on any early return
1981        // below; storing it in `PersistenceCtx` ties its lifetime
1982        // to the live Database handle. See `LockRegistryGuard`
1983        // docs for why on-disk identity alone wasn't enough.
1984        let lock_registry_guard = LockRegistryGuard::try_acquire(&lock_path)?;
1985        acquire_path_lock(&lock_path)?;
1986        stage("locks", &mut last_stage);
1987        let mut engine = if db_path.exists() {
1988            let bytes = std::fs::read(&db_path).map_err(io_err)?;
1989            stage("fs::read_catalog", &mut last_stage);
1990            let engine = Engine::restore_envelope(&bytes).map_err(|e| {
1991                EngineError::Storage(spg_storage::StorageError::Corrupt(format!(
1992                    "restore from {}: {e}",
1993                    db_path.display()
1994                )))
1995            })?;
1996            stage("restore_envelope", &mut last_stage);
1997            engine_with_query_byte_budget(engine.with_clock(wall_clock_micros))
1998        } else {
1999            engine_with_query_byte_budget(Engine::new().with_clock(wall_clock_micros))
2000        };
2001        // v7.1.4 — manifest-driven cold-segment reload. The
2002        // manifest sidecar pairs the catalog snapshot CRC with a
2003        // list of `(segment_id, path, crc32)` triples; verify
2004        // before loading so a torn or stale manifest doesn't
2005        // surface phantom data.
2006        let cold_segments_dir = {
2007            let parent = db_path.parent().unwrap_or_else(|| Path::new("."));
2008            let stem = db_path
2009                .file_stem()
2010                .unwrap_or_else(|| std::ffi::OsStr::new("db"))
2011                .to_string_lossy()
2012                .into_owned();
2013            parent.join(format!("{stem}.spg")).join("segments")
2014        };
2015        let mut cold_segment_paths: BTreeMap<u32, PathBuf> = BTreeMap::new();
2016        let manifest_pth = spg_manifest_path(&db_path);
2017        if manifest_pth.exists() && db_path.exists() {
2018            let m_bytes = std::fs::read(&manifest_pth).map_err(io_err)?;
2019            if let Ok(m) = CatalogManifest::deserialize(&m_bytes) {
2020                let snap_bytes = std::fs::read(&db_path).map_err(io_err)?;
2021                let snap_crc = spg_crypto::crc32::crc32(&snap_bytes);
2022                if snap_crc == m.catalog_crc32 {
2023                    for entry in &m.cold_segments {
2024                        if let Ok(seg_bytes) = std::fs::read(&entry.path) {
2025                            let computed = spg_crypto::crc32::crc32(&seg_bytes);
2026                            if computed != entry.crc32 {
2027                                eprintln!(
2028                                    "spg-embedded: manifest skip segment {}: CRC mismatch",
2029                                    entry.segment_id
2030                                );
2031                                continue;
2032                            }
2033                            if engine.catalog().cold_segment(entry.segment_id).is_some() {
2034                                // Already loaded via Catalog::clone path (shouldn't happen
2035                                // since Engine::new + restore_envelope don't populate cold).
2036                                continue;
2037                            }
2038                            let mut new_cat = engine.catalog().clone();
2039                            if let Err(e) =
2040                                new_cat.load_segment_bytes_at(entry.segment_id, seg_bytes)
2041                            {
2042                                eprintln!(
2043                                    "spg-embedded: manifest load segment {} failed: {e}",
2044                                    entry.segment_id
2045                                );
2046                                continue;
2047                            }
2048                            engine.replace_catalog(new_cat);
2049                            cold_segment_paths.insert(entry.segment_id, entry.path.clone());
2050                        } else {
2051                            eprintln!(
2052                                "spg-embedded: manifest skip segment {}: file unreadable",
2053                                entry.segment_id
2054                            );
2055                        }
2056                    }
2057                }
2058            }
2059        }
2060        // CoW-4 (v7.34) — D10 + missing-manifest fallback. Walk
2061        // `<db>.spg/segments/` and attach any `seg_<id>.spg` file that
2062        // the manifest didn't already cover (manifest absent / CRC
2063        // mismatched / a fresher freeze landed after the last
2064        // checkpoint wrote its manifest). The segment binary's own
2065        // magic + CRC32 guards integrity — no need to trust a stale
2066        // manifest entry to trust the file.
2067        stage("manifest+cold_segments", &mut last_stage);
2068        scan_cold_segments_dir(&cold_segments_dir, &mut engine, &mut cold_segment_paths);
2069        stage("scan_cold_segments_dir", &mut last_stage);
2070        // v7.19 — chunked WAL on-disk layout.
2071        //
2072        // Three cases handled here:
2073        //
2074        // 1. wal_dir exists as a DIRECTORY → scan its
2075        //    `<unix_us>_<leading_lsn>.wal` chunks (sorted
2076        //    lexicographically = chunk-creation order), replay
2077        //    them in sequence, advance the LSN watermark to the
2078        //    max commit_lsn seen.
2079        //
2080        // 2. wal_path exists as a FILE → legacy v7.18 layout.
2081        //    Migrate it: create `wal_dir/`, move the single file
2082        //    inside as `0000000000000000_0000000000000000.wal`,
2083        //    then fall through to case 1's replay loop.
2084        //
2085        // 3. Neither exists → fresh database; create wal_dir.
2086        let mut initial_lsn: u64 = 0;
2087        if wal_path.is_file() {
2088            // Case 2: legacy single-file WAL migration.
2089            let legacy_bytes = std::fs::read(&wal_path).map_err(io_err)?;
2090            std::fs::remove_file(&wal_path).map_err(io_err)?;
2091            std::fs::create_dir_all(&wal_dir).map_err(io_err)?;
2092            if !legacy_bytes.is_empty() {
2093                let migrated = wal_dir.join(legacy_chunk_filename());
2094                std::fs::write(&migrated, &legacy_bytes).map_err(io_err)?;
2095            }
2096        } else if !wal_dir.exists() {
2097            // Case 3: fresh database.
2098            std::fs::create_dir_all(&wal_dir).map_err(io_err)?;
2099        }
2100        // Cases 1 + 2 share replay logic now that wal_dir is
2101        // guaranteed to exist (and may be empty for case 3).
2102        //
2103        // Two-pass replay so we don't double-apply records the
2104        // snapshot already reflects:
2105        //
2106        // 1. Find the highest commit_lsn carried by a
2107        //    checkpoint_marker across all chunks. That LSN is the
2108        //    snapshot's high-water mark — anything ≤ it is
2109        //    already in `<db_path>` and replaying it would
2110        //    DuplicateTable / double-insert.
2111        // 2. Replay only records strictly above that LSN.
2112        //
2113        // Case 2 migration (legacy single-file WAL) lands here
2114        // too: the migrated chunk has no marker so the LSN floor
2115        // is 0 and every record applies — exactly the v7.18
2116        // behaviour the migration is supposed to preserve.
2117        let chunk_paths = sorted_wal_chunks(&wal_dir).map_err(io_err)?;
2118        stage("wal::sorted_chunks", &mut last_stage);
2119        let mut snapshot_lsn: u64 = 0;
2120        for chunk in &chunk_paths {
2121            let bytes = std::fs::read(chunk).map_err(io_err)?;
2122            if let Ok(records) = parse_wal_records(&bytes) {
2123                for r in &records {
2124                    if r.type_byte == WAL_V4_TYPE_CHECKPOINT_MARKER {
2125                        if let Some(l) = r.commit_lsn {
2126                            if l > snapshot_lsn {
2127                                snapshot_lsn = l;
2128                            }
2129                        }
2130                    }
2131                }
2132            }
2133        }
2134        stage("wal::snapshot_lsn_scan", &mut last_stage);
2135        let mut quarantined: Vec<QuarantinedStmt> = Vec::new();
2136        for chunk in &chunk_paths {
2137            let bytes = std::fs::read(chunk).map_err(io_err)?;
2138            if bytes.is_empty() {
2139                continue;
2140            }
2141            replay_wal_filtered(&bytes, &mut engine, snapshot_lsn, &mut quarantined)
2142                .map_err(|m| EngineError::Storage(spg_storage::StorageError::Corrupt(m)))?;
2143            if let Ok(records) = parse_wal_records(&bytes) {
2144                if let Some(max) = records.iter().filter_map(|r| r.commit_lsn).max() {
2145                    if max > initial_lsn {
2146                        initial_lsn = max;
2147                    }
2148                }
2149            }
2150        }
2151        stage("wal::replay_filtered", &mut last_stage);
2152        // v7.30.1 (mailrs round-24 ask 2) — replay rejects no longer
2153        // brick the open. Persist the rejected statements beside the
2154        // WAL chunks for forensics and say so loudly; the boot
2155        // continues with every other record applied.
2156        if !quarantined.is_empty() {
2157            let mut body = String::new();
2158            for q in &quarantined {
2159                body.push_str(&format_quarantine_line(q));
2160            }
2161            let qpath = wal_dir.join(format!(
2162                "quarantine-{:016x}.log",
2163                wall_clock_micros().max(0) as u64
2164            ));
2165            match std::fs::write(&qpath, &body) {
2166                Ok(()) => eprintln!(
2167                    "spg-embedded: WAL replay quarantined {} statement(s) — boot continues; \
2168                     forensics at {}",
2169                    quarantined.len(),
2170                    qpath.display()
2171                ),
2172                Err(e) => eprintln!(
2173                    "spg-embedded: WAL replay quarantined {} statement(s) — boot continues; \
2174                     quarantine file write FAILED ({e}), entries follow:\n{body}",
2175                    quarantined.len()
2176                ),
2177            }
2178        }
2179        // Open the "current" chunk — either the last existing
2180        // chunk file (so subsequent appends extend it until the
2181        // size threshold rotates) or a fresh first chunk.
2182        let now_us = wall_clock_micros();
2183        let current_chunk_path = if let Some(last) = chunk_paths.last() {
2184            last.clone()
2185        } else {
2186            wal_dir.join(chunk_filename(now_us, initial_lsn + 1))
2187        };
2188        let wal_file = OpenOptions::new()
2189            .create(true)
2190            .append(true)
2191            .read(true)
2192            .open(&current_chunk_path)
2193            .map_err(io_err)?;
2194        // Persist the (possibly freshly created) chunk's directory entry.
2195        fsync_dir(&wal_dir);
2196        let wal_len = wal_file.metadata().map_err(io_err)?.len();
2197        let wal = Arc::new(WalGroup::new(wal_file, wal_len));
2198        // v7.19 P3 — spawn retention sweep thread when the
2199        // operator opted in via SPG_PITR_RETENTION_HOURS > 0.
2200        // Otherwise stay on the v7.18 behaviour (chunks accumulate
2201        // until something else — backup-pitr archival, manual
2202        // cleanup — moves them).
2203        let retention_hours = pitr_retention_hours();
2204        let (retention_shutdown, retention_thread) = if retention_hours > 0 {
2205            let shutdown = Arc::new(AtomicBool::new(false));
2206            let shutdown_clone = Arc::clone(&shutdown);
2207            let wal_dir_clone = wal_dir.clone();
2208            let check_interval = std::time::Duration::from_secs(pitr_retention_check_sec());
2209            let archive_cmd = pitr_archive_cmd();
2210            let handle = std::thread::Builder::new()
2211                .name("spg-pitr-retention".into())
2212                .spawn(move || {
2213                    retention_sweep_loop(
2214                        wal_dir_clone,
2215                        retention_hours,
2216                        check_interval,
2217                        archive_cmd,
2218                        shutdown_clone,
2219                    );
2220                })
2221                .map_err(io_err)?;
2222            (Some(shutdown), Some(handle))
2223        } else {
2224            (None, None)
2225        };
2226        // v7.20 — background flusher for SPG_SYNCHRONOUS_COMMIT=off.
2227        let (flusher_shutdown, flusher_thread) = if synchronous_commit_on() {
2228            (None, None)
2229        } else {
2230            let shutdown = Arc::new(AtomicBool::new(false));
2231            let shutdown_clone = Arc::clone(&shutdown);
2232            let group = Arc::clone(&wal);
2233            let interval = std::time::Duration::from_millis(wal_writer_delay_ms());
2234            let handle = std::thread::Builder::new()
2235                .name("spg-wal-flusher".into())
2236                .spawn(move || {
2237                    while !shutdown_clone.load(Ordering::SeqCst) {
2238                        std::thread::sleep(interval);
2239                        if let Err(e) = group.flush_now() {
2240                            eprintln!("spg-embedded: background WAL flush failed: {e:?}");
2241                        }
2242                    }
2243                    // Final drain on shutdown signal.
2244                    let _ = group.flush_now();
2245                })
2246                .map_err(io_err)?;
2247            (Some(shutdown), Some(handle))
2248        };
2249        // v7.34 (crash-recovery P0 #2) — arm row-level redo capture for
2250        // subsequent writes (AFTER replay, so re-executed SQL records
2251        // don't capture; 0x13 records replay via apply_redo and never do).
2252        if row_redo_enabled() {
2253            engine.set_redo_capture(true);
2254        }
2255        let db = Self {
2256            engine,
2257            commit_lsn: AtomicU64::new(initial_lsn),
2258            tx_wal: None,
2259            persistence: Some(PersistenceCtx {
2260                db_path,
2261                wal_dir,
2262                current_chunk_path: Arc::new(Mutex::new(current_chunk_path)),
2263                wal,
2264                checkpoint_threshold_bytes: default_checkpoint_threshold_bytes(),
2265                cold_segments_dir,
2266                cold_segment_paths,
2267                lock_path,
2268                lock_registry_guard,
2269                retention_shutdown,
2270                retention_thread,
2271                flusher_shutdown,
2272                flusher_thread,
2273                checkpoint_worker: Some(CheckpointWorker::spawn()),
2274            }),
2275        };
2276        // v7.37.2 (mailrs prod 7.35 pool-exhaustion incident — surface
2277        // fix per `feedback-zero-customer-change-warmup-incident`) —
2278        // automatic cold-tier OS page-cache warm-up so the catalog is
2279        // fully server-ready on return. The client never sees a SPG-
2280        // specific call site; `open_path` behaves like PG's "ready to
2281        // accept queries" semantics. Bounded by
2282        // `SPG_WARM_UP_COLD_BUDGET_MS` (default unset = no cap;
2283        // env-only spec channel, never a client-visible API). `0` =
2284        // skip warm-up entirely (escape hatch for fast restart).
2285        stage("pre_autowarm", &mut last_stage);
2286        autowarm_cold_tier_on_open(&db);
2287        stage("autowarm", &mut last_stage);
2288        Ok(db)
2289    }
2290
2291    /// v7.1.4 — freeze the oldest `max_rows` of `table_name`'s
2292    /// hot tier into a brand-new cold-tier segment + persist
2293    /// it to disk. Same semantics as `spg-server`'s freezer
2294    /// thread; embedded just runs the freeze synchronously on
2295    /// the caller's thread. Persistence + manifest update
2296    /// happen as part of the next `checkpoint()` (or on Drop).
2297    pub fn freeze_oldest_to_cold(
2298        &mut self,
2299        table_name: &str,
2300        index_name: &str,
2301        max_rows: usize,
2302    ) -> Result<spg_storage::FreezeReport, EngineError> {
2303        let report = self
2304            .engine
2305            .freeze_oldest_to_cold(table_name, index_name, max_rows)?;
2306        if let Some(p) = &mut self.persistence {
2307            std::fs::create_dir_all(&p.cold_segments_dir).map_err(io_err)?;
2308            let final_path = p
2309                .cold_segments_dir
2310                .join(format!("seg_{}.spg", report.segment_id));
2311            let tmp_path = p
2312                .cold_segments_dir
2313                .join(format!("seg_{}.spg.tmp", report.segment_id));
2314            std::fs::write(&tmp_path, &report.segment_bytes).map_err(io_err)?;
2315            std::fs::rename(&tmp_path, &final_path).map_err(io_err)?;
2316            p.cold_segment_paths.insert(report.segment_id, final_path);
2317        }
2318        Ok(report)
2319    }
2320
2321    /// v7.1 — override the auto-checkpoint WAL-size ceiling for
2322    /// this `Database` instance. Default is
2323    /// `SPG_EMBEDDED_CHECKPOINT_BYTES` env (4 MiB if unset); the
2324    /// setter wins. No-op when the database is in-memory.
2325    pub fn set_checkpoint_threshold_bytes(&mut self, bytes: u64) {
2326        if let Some(p) = &mut self.persistence {
2327            p.checkpoint_threshold_bytes = bytes.max(1);
2328        }
2329    }
2330
2331    /// v7.31 (memory campaign, round-26 ask 1/ask 4) — per-bucket
2332    /// memory snapshot for the embedding host. Poll it from prod to
2333    /// see where resident bytes live (rows / representation /
2334    /// indexes per table) and to drive host-side shedding before
2335    /// the kernel does it. Same numbers as the server path's
2336    /// `SELECT * FROM spg_memory_stats`.
2337    #[must_use]
2338    pub fn memory_stats(&self) -> spg_engine::MemoryStats {
2339        let mut stats = self.engine.memory_stats();
2340        // v7.31 C2 — fill in bucket D: the engine leaves `wal_bytes`
2341        // None (it has no WAL); we report the live (uncheckpointed)
2342        // WAL footprint via the same `written_len()` meter `metrics()`
2343        // reads. In-memory databases have no persistence → stays None.
2344        if let Some(p) = &self.persistence {
2345            stats.wal_bytes = Some(p.wal.written_len());
2346        }
2347        stats
2348    }
2349
2350    /// v7.1 — flush a fresh catalog snapshot to `db_path` and
2351    /// rotate the WAL. Idempotent; cheap when nothing has happened
2352    /// since the last checkpoint. No-op when the database is in-memory.
2353    ///
2354    /// CoW-2 (v7.34): the heavy half (serialize + tmp+rename + fsync +
2355    /// marker enqueue + chunk rotation) runs on a dedicated worker thread
2356    /// so the caller's engine borrow is released after the cheap capture
2357    /// step. This entry point keeps the **synchronous** contract — it
2358    /// waits for the worker to finish before returning — so existing
2359    /// callers, tests, and operator scripts see no behaviour change;
2360    /// they just pay one extra hop. The non-blocking variant lives at
2361    /// `trigger_checkpoint`, used by the auto-checkpoint hot path so
2362    /// the write that crossed `SPG_EMBEDDED_CHECKPOINT_BYTES` doesn't
2363    /// stall on disk IO.
2364    ///
2365    /// Called automatically when:
2366    /// - the WAL grows past `SPG_EMBEDDED_CHECKPOINT_BYTES` (default
2367    ///   4 MiB) at the end of an `execute()` (via `trigger_checkpoint`,
2368    ///   non-blocking), and
2369    /// - `Drop` runs (synchronous; best-effort, failures logged).
2370    pub fn checkpoint(&mut self) -> Result<(), EngineError> {
2371        if self.persistence.is_none() {
2372            return Ok(());
2373        }
2374        // Drain any prior async checkpoint first so our snapshot reflects
2375        // post-it state (and so a sticky error from it surfaces here, not
2376        // smeared across the next two `wait`s).
2377        self.wait_checkpoint()?;
2378        let Some(job) = self.snapshot_checkpoint_job() else {
2379            return Ok(());
2380        };
2381        let Some(worker) = self
2382            .persistence
2383            .as_ref()
2384            .and_then(|p| p.checkpoint_worker.as_ref())
2385        else {
2386            return Ok(());
2387        };
2388        // `wait_checkpoint` above guaranteed idle; `try_enqueue` only
2389        // returns Ok(false) when busy, so we expect Ok(true) here. The
2390        // bool is dropped — we wait unconditionally to honour the sync
2391        // contract.
2392        let _ = worker.try_enqueue(job)?;
2393        self.wait_checkpoint()
2394    }
2395
2396    /// CoW-2 (v7.34) — non-blocking checkpoint trigger used by the
2397    /// auto-checkpoint hot path (`wal_after_ok` over the threshold).
2398    /// Captures the engine state under `&mut self` then signals the
2399    /// background worker and returns; the serialize / fsync / rotate
2400    /// sequence runs on the worker thread. If a checkpoint is already
2401    /// pending or in flight, the new trigger is silently dropped —
2402    /// the next threshold crossing picks up the newer state.
2403    ///
2404    /// Sticky errors from a prior async run surface here (via
2405    /// `try_enqueue`), so a failed background checkpoint still reaches
2406    /// the caller eventually rather than vanishing.
2407    fn trigger_checkpoint(&mut self) -> Result<(), EngineError> {
2408        if self.persistence.is_none() {
2409            return Ok(());
2410        }
2411        let Some(job) = self.snapshot_checkpoint_job() else {
2412            return Ok(());
2413        };
2414        let Some(worker) = self
2415            .persistence
2416            .as_ref()
2417            .and_then(|p| p.checkpoint_worker.as_ref())
2418        else {
2419            return Ok(());
2420        };
2421        let _accepted = worker.try_enqueue(job)?;
2422        Ok(())
2423    }
2424
2425    /// CoW-2 (v7.34) — block until the background checkpoint worker is
2426    /// idle. Used by sync `checkpoint()` and by Drop to ensure the final
2427    /// snapshot is durable before the process exits.
2428    fn wait_checkpoint(&self) -> Result<(), EngineError> {
2429        match self
2430            .persistence
2431            .as_ref()
2432            .and_then(|p| p.checkpoint_worker.as_ref())
2433        {
2434            Some(w) => w.wait(),
2435            None => Ok(()),
2436        }
2437    }
2438
2439    /// CoW-2 (v7.34) — capture a checkpoint job under `&mut self` (or
2440    /// `&self`, since reading from atomics + cheap clones don't mutate).
2441    /// Returns `None` if the database is in-memory.
2442    fn snapshot_checkpoint_job(&self) -> Option<CheckpointJob> {
2443        let p = self.persistence.as_ref()?;
2444        Some(CheckpointJob {
2445            snapshot: self.engine.snapshot_data(),
2446            marker_lsn: self.commit_lsn.load(Ordering::SeqCst),
2447            db_path: p.db_path.clone(),
2448            wal_dir: p.wal_dir.clone(),
2449            wal: Arc::clone(&p.wal),
2450            cold_segments: p
2451                .cold_segment_paths
2452                .iter()
2453                .map(|(&id, path)| (id, path.clone()))
2454                .collect(),
2455            current_chunk_path: Arc::clone(&p.current_chunk_path),
2456        })
2457    }
2458
2459    /// Restore a database from a previously-captured catalog
2460    /// snapshot. Pairs with `Database::snapshot()` for
2461    /// round-tripping in-memory state without going through
2462    /// the `spg-server` WAL.
2463    pub fn restore(snapshot: &[u8]) -> Result<Self, EngineError> {
2464        let engine = Engine::restore_envelope(snapshot).map_err(|e| {
2465            EngineError::Storage(spg_storage::StorageError::Corrupt(format!("restore: {e}")))
2466        })?;
2467        let db = Self {
2468            engine,
2469            persistence: None,
2470            commit_lsn: AtomicU64::new(0),
2471            tx_wal: None,
2472        };
2473        // v7.37.2 — auto-warm on snapshot restore for the same reason
2474        // `open_path` does (catalog is server-ready when constructor
2475        // returns; client never sees a SPG-specific warmup call).
2476        autowarm_cold_tier_on_open(&db);
2477        Ok(db)
2478    }
2479
2480    /// Take a catalog snapshot suitable for `Database::restore`.
2481    /// The bytes are SPG's canonical catalog envelope (FILE_MAGIC
2482    /// + version + payload); round-trips through every released
2483    /// SPG version per the STABILITY contract.
2484    #[must_use]
2485    pub fn snapshot(&self) -> Vec<u8> {
2486        self.engine.snapshot()
2487    }
2488
2489    /// v7.36 (mailrs ask #4) — programmatic `EXPLAIN` over `sql`,
2490    /// returning each line of the QUERY PLAN as an owned `String`.
2491    /// Skips the WAL (`EXPLAIN` is read-only) and runs against the
2492    /// engine's live catalog. Dogfood callers can attach the plan
2493    /// to a report or assert on its shape from a test without
2494    /// having to parse a tabular result themselves.
2495    ///
2496    /// `sql` is the inner SELECT (no `EXPLAIN` prefix); the helper
2497    /// adds it. For SQL with `$N` placeholders, substitute them
2498    /// into the SQL string before calling — programmatic
2499    /// placeholder-aware EXPLAIN is on the v7.37 plan.
2500    ///
2501    /// # Errors
2502    /// Propagates parse errors on `sql`, plus any engine error the
2503    /// `EXPLAIN` itself raises (table not found, column not found).
2504    pub fn explain(&self, sql: &str) -> Result<Vec<String>, EngineError> {
2505        let full = format!("EXPLAIN {sql}");
2506        let result = self.engine.execute_readonly(&full)?;
2507        Ok(extract_query_plan_lines(result))
2508    }
2509
2510    /// Write-side single-statement execute. Runs the SQL through
2511    /// the buffered group-commit pipeline and blocks until the
2512    /// resulting batch's WAL fsync returns. Read-only statements
2513    /// (SELECT / SHOW / EXPLAIN / BEGIN-COMMIT-ROLLBACK /
2514    /// CHECKPOINT / COMPACT etc.) skip the WAL entirely.
2515    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
2516        // v7.20 P2 — single-caller convenience over the buffered
2517        // path: enqueue + immediately wait. Batch size is 1 here,
2518        // so the durability behaviour (one fsync before Ok) is
2519        // identical to v7.19. Concurrent callers go through
2520        // `execute_buffered` (AsyncDatabase does) and share the
2521        // leader's fsync.
2522        let (result, ticket) = self.execute_buffered(sql)?;
2523        if let Some(t) = ticket {
2524            t.wait()?;
2525        }
2526        Ok(result)
2527    }
2528
2529    /// v7.20 P2 — group-commit write entry. Runs the engine
2530    /// mutation + encodes/enqueues the WAL record, then RETURNS
2531    /// WITHOUT waiting for the fsync. The caller must call
2532    /// [`WalTicket::wait`] before treating the write as durable
2533    /// — crucially, the caller can (and should) drop whatever
2534    /// lock guards this `Database` first, so the next writer's
2535    /// mutation overlaps this batch's fsync.
2536    ///
2537    /// `None` ticket = nothing hit the WAL (read-only statement,
2538    /// no-op DDL, or in-memory database) — the result is final
2539    /// as returned.
2540    ///
2541    /// # Errors
2542    /// Engine errors propagate unchanged. Auto-checkpoint (when
2543    /// the active chunk crosses the threshold) runs inline and
2544    /// may surface IO errors.
2545    pub fn execute_buffered(
2546        &mut self,
2547        sql: &str,
2548    ) -> Result<(QueryResult, Option<WalTicket>), EngineError> {
2549        let result = self.engine.execute(sql)?;
2550        let modified = matches!(
2551            &result,
2552            QueryResult::CommandOk {
2553                modified_catalog: true,
2554                ..
2555            }
2556        );
2557        let ticket = self.wal_after_ok(sql, modified)?;
2558        Ok((result, ticket))
2559    }
2560
2561    /// v7.21 (round-12 polish) — post-engine WAL bookkeeping shared
2562    /// by the simple ([`Self::execute_buffered`]) and prepared
2563    /// ([`Self::execute_prepared_buffered`]) write paths. `canonical`
2564    /// is the replay text (bind-final for prepared statements);
2565    /// `modified_catalog` comes from the engine result. Three routes:
2566    ///
2567    /// - transaction control → maintain [`Self::tx_wal`]: BEGIN opens
2568    ///   the buffer, COMMIT flushes it as ONE atomic
2569    ///   `WAL_V4_TYPE_TX_COMMIT_SQL` record, ROLLBACK drops it,
2570    ///   SAVEPOINT / ROLLBACK TO mark / truncate it. The engine has
2571    ///   already accepted the statement, so this only mirrors state.
2572    /// - inside an open transaction → buffer the statement (shadow-
2573    ///   catalog mutations report `modified_catalog: false`, so the
2574    ///   auto-commit arm below can't see them).
2575    /// - auto-commit mutation → classic per-statement v4 record.
2576    ///
2577    /// v7.18 PITR — v4 records carry commit LSN + wall-clock micros.
2578    /// The crash window remains one BATCH: replay re-applies
2579    /// idempotently exactly as before, and a torn batch tail drops
2580    /// cleanly (same torn-write handling).
2581    fn wal_after_ok(
2582        &mut self,
2583        canonical: &str,
2584        modified_catalog: bool,
2585    ) -> Result<Option<WalTicket>, EngineError> {
2586        if self.persistence.is_none() {
2587            return Ok(None);
2588        }
2589        let mut record = None;
2590        match tx_control_kind(canonical) {
2591            Some(TxControl::Begin) => {
2592                self.tx_wal = Some(TxWalBuffer::default());
2593            }
2594            Some(TxControl::Commit) => {
2595                if let Some(buf) = self.tx_wal.take()
2596                    && !buf.statements.is_empty()
2597                {
2598                    let script = buf.statements.join(";\n");
2599                    let lsn = self.commit_lsn.fetch_add(1, Ordering::SeqCst) + 1;
2600                    record = Some(encode_v4_tx_commit(&script, lsn, wall_clock_micros()));
2601                }
2602            }
2603            Some(TxControl::Rollback) => {
2604                self.tx_wal = None;
2605            }
2606            Some(TxControl::Savepoint(name)) => {
2607                if let Some(buf) = &mut self.tx_wal {
2608                    // PG name-reuse semantics: latest mark wins.
2609                    buf.savepoints.retain(|(n, _)| n != &name);
2610                    let mark = buf.statements.len();
2611                    buf.savepoints.push((name, mark));
2612                }
2613            }
2614            Some(TxControl::RollbackToSavepoint(name)) => {
2615                if let Some(buf) = &mut self.tx_wal
2616                    && let Some(pos) = buf.savepoints.iter().position(|(n, _)| n == &name)
2617                {
2618                    let mark = buf.savepoints[pos].1;
2619                    buf.statements.truncate(mark);
2620                    // Later savepoints die with the rollback; the
2621                    // target itself survives (PG keeps it
2622                    // re-rollbackable).
2623                    buf.savepoints.truncate(pos + 1);
2624                }
2625            }
2626            Some(TxControl::ReleaseSavepoint) => {
2627                // RELEASE folds the savepoint into the enclosing tx —
2628                // buffered statements stay. The mark also stays:
2629                // marks are only consulted by ROLLBACK TO, which the
2630                // engine validates first, so a dangling mark is
2631                // unreachable.
2632            }
2633            None => {
2634                if let Some(buf) = &mut self.tx_wal {
2635                    if !sql_is_read_only(canonical) {
2636                        buf.statements.push(canonical.to_string());
2637                    }
2638                } else if modified_catalog && !sql_is_read_only(canonical) {
2639                    let lsn = self.commit_lsn.fetch_add(1, Ordering::SeqCst) + 1;
2640                    // v7.34 (crash-recovery P0 #2) — hybrid log: when
2641                    // row-level redo is on and this statement produced row
2642                    // changes (DML), write a physical 0x13 redo record so
2643                    // replay applies it directly. A statement with no row
2644                    // changes (DDL: CREATE/ALTER, never goes through
2645                    // Table::insert/update/delete) drains an empty redo and
2646                    // keeps the SQL record so the schema still replays.
2647                    let redo = if row_redo_enabled() {
2648                        self.engine.take_redo()
2649                    } else {
2650                        Vec::new()
2651                    };
2652                    record = Some(if redo.is_empty() {
2653                        encode_v4_auto_commit(canonical, lsn, wall_clock_micros())
2654                    } else {
2655                        encode_v5_row_redo(
2656                            &spg_storage::encode_redo_log(&redo),
2657                            lsn,
2658                            wall_clock_micros(),
2659                        )
2660                    });
2661                }
2662            }
2663        }
2664        let mut ticket = None;
2665        if let Some(record) = record {
2666            let p = self.persistence.as_mut().expect("checked above");
2667            let seq = p.wal.enqueue(&record);
2668            ticket = Some(WalTicket {
2669                group: Arc::clone(&p.wal),
2670                seq,
2671            });
2672            if p.wal.written_len() >= p.checkpoint_threshold_bytes {
2673                // CoW-2 (v7.34): hot path — fire-and-forget. The worker
2674                // serializes off this thread so the commit that just
2675                // crossed the threshold doesn't stall on a multi-hundred-ms
2676                // snapshot write. Any sticky error from a prior async
2677                // checkpoint surfaces here.
2678                self.trigger_checkpoint()?;
2679            }
2680        }
2681        Ok(ticket)
2682    }
2683
2684    /// v7.3.0 — typed-row variant of [`Database::query`]. Each
2685    /// row decodes into a `T: FromSpgRow` so callers don't
2686    /// pattern-match on `Value` themselves. Use [`spg_row!`] to
2687    /// generate the impl, or write it by hand.
2688    pub fn query_typed<T: FromSpgRow>(&mut self, sql: &str) -> Result<Vec<T>, EngineError> {
2689        let rows = self.query(sql)?;
2690        rows.into_iter().map(|r| T::from_spg_row(&r)).collect()
2691    }
2692
2693    /// Run a SELECT and return rows as a `Vec<Vec<Value>>` —
2694    /// strips the column-schema metadata for read-side
2695    /// ergonomics. Errors on non-Rows results (DML / DDL
2696    /// statements should go through `execute` instead).
2697    pub fn query(&mut self, sql: &str) -> Result<Vec<Vec<Value<'static>>>, EngineError> {
2698        match self.engine.execute(sql)? {
2699            QueryResult::Rows { rows, .. } => Ok(rows.into_iter().map(|r| r.values).collect()),
2700            QueryResult::CommandOk { .. } => Err(EngineError::Unsupported(
2701                "query() expects a SELECT — use execute() for DML/DDL".into(),
2702            )),
2703            // v7.5.0 — QueryResult is #[non_exhaustive]; any future
2704            // variant is not a SELECT row stream, treat as Unsupported.
2705            _ => Err(EngineError::Unsupported(
2706                "query() expects a SELECT — use execute() for DML/DDL".into(),
2707            )),
2708        }
2709    }
2710
2711    /// v7.16.0 — column-aware variant of [`Self::query`].
2712    /// Returns the column schema vec alongside the rows so
2713    /// adapters (the spg-sqlx Row impl most notably) can drive
2714    /// name + type-based column lookups. Errors on non-Rows
2715    /// results identically to `query`.
2716    pub fn query_with_columns(
2717        &mut self,
2718        sql: &str,
2719    ) -> Result<(Vec<spg_storage::ColumnSchema>, Vec<Vec<Value<'static>>>), EngineError> {
2720        match self.engine.execute(sql)? {
2721            QueryResult::Rows { columns, rows } => {
2722                Ok((columns, rows.into_iter().map(|r| r.values).collect()))
2723            }
2724            QueryResult::CommandOk { .. } => Err(EngineError::Unsupported(
2725                "query_with_columns() expects a SELECT — use execute() for DML/DDL".into(),
2726            )),
2727            _ => Err(EngineError::Unsupported(
2728                "query_with_columns() expects a SELECT — use execute() for DML/DDL".into(),
2729            )),
2730        }
2731    }
2732
2733    /// v7.16.0 — column-aware variant of
2734    /// [`Self::query_prepared`]. Same shape as
2735    /// `query_with_columns` but driven from a prepared
2736    /// statement + bound params.
2737    pub fn query_prepared_with_columns(
2738        &mut self,
2739        stmt: &Statement,
2740        params: &[Value<'static>],
2741    ) -> Result<(Vec<spg_storage::ColumnSchema>, Vec<Vec<Value<'static>>>), EngineError> {
2742        match self.engine.execute_prepared(stmt.stmt.clone(), params)? {
2743            QueryResult::Rows { columns, rows } => {
2744                Ok((columns, rows.into_iter().map(|r| r.values).collect()))
2745            }
2746            QueryResult::CommandOk { .. } => Err(EngineError::Unsupported(
2747                "query_prepared_with_columns() expects a SELECT — use execute_prepared() for DML/DDL".into(),
2748            )),
2749            _ => Err(EngineError::Unsupported(
2750                "query_prepared_with_columns() expects a SELECT — use execute_prepared() for DML/DDL".into(),
2751            )),
2752        }
2753    }
2754
2755    /// Borrow the underlying engine. Escape hatch for callers
2756    /// that need access to `spg-engine` APIs not yet surfaced
2757    /// here (transactions, EXPLAIN ANALYZE, etc.).
2758    #[must_use]
2759    pub const fn engine(&self) -> &Engine {
2760        &self.engine
2761    }
2762
2763    /// Mutable borrow of the underlying engine. Same intent as
2764    /// `engine()` but for write-side APIs (e.g. inserting
2765    /// directly through `Catalog::insert` for high-throughput
2766    /// bulk loads that bypass SQL parsing).
2767    pub const fn engine_mut(&mut self) -> &mut Engine {
2768        &mut self.engine
2769    }
2770
2771    /// v7.38 (mailrs prod 7.35 pool-exhaustion incident) — boot-time
2772    /// plan-IR cache warm-up. Pre-prepares the listed SQL shapes so
2773    /// the first user-facing request doesn't pay the 2-3 s
2774    /// first-fire parse + JOIN-reorder cost on the readonly-blocking
2775    /// pool. Recommended call site: `Database::new` immediately after
2776    /// catalog restore, before serving any traffic. Returns the
2777    /// number of statements successfully cached.
2778    pub fn warm_up_plan_cache(&mut self, sqls: &[&str]) -> usize {
2779        self.engine.warm_up_plan_cache(sqls)
2780    }
2781
2782    /// v7.38 (mailrs prod 7.35 pool-exhaustion incident) — boot-time
2783    /// cold-tier OS page-cache warm-up. Touches every cold segment
2784    /// file in the active catalog so the kernel page cache loads
2785    /// them before user traffic arrives. On a hot-only catalog the
2786    /// call is a near-no-op. Returns the total cold rows touched.
2787    pub fn warm_up_cold_tier(&self) -> usize {
2788        self.engine.warm_up_cold_tier()
2789    }
2790
2791    /// v7.16.0 — parse + plan a SQL string ONCE so subsequent
2792    /// `execute_prepared` / `query_prepared` calls can re-bind
2793    /// parameters without re-parsing. The returned [`Statement`]
2794    /// is a thin handle around the AST + cached source SQL; it's
2795    /// `Clone` so the same plan can drive many bind calls
2796    /// concurrently (each call clones the AST and runs
2797    /// placeholder substitution on the clone — the cached
2798    /// plan stays intact).
2799    ///
2800    /// Plan caching follows the engine's existing version-aware
2801    /// rule: a prepared `Statement` whose statistics version
2802    /// has rolled (ANALYZE ran between prepare and execute)
2803    /// will silently re-prepare under the hood. Callers don't
2804    /// need to detect this.
2805    ///
2806    /// Placeholders in the SQL use PG's `$1`, `$2`, … convention.
2807    /// `bind`-time `Value`s are passed as a slice; arity
2808    /// mismatches surface as `EvalError::PlaceholderOutOfRange`
2809    /// at `execute_prepared` time, not here.
2810    ///
2811    /// # Errors
2812    /// Surfaces `EngineError` (parse error / plan rewrite
2813    /// failure) from the underlying `Engine::prepare`.
2814    pub fn prepare(&mut self, sql: &str) -> Result<Statement, EngineError> {
2815        // Use the cached path so repeated prepares of the same
2816        // SQL are O(1). The engine's plan cache stays shared
2817        // across all callers of this Database — a single
2818        // `PgPool`-shaped consumer (or, later, the spg-sqlx
2819        // adapter) prepares once and reaps the win on every bind.
2820        let stmt = self
2821            .engine
2822            .prepare_cached(sql)
2823            .map_err(EngineError::Parse)?;
2824        Ok(Statement {
2825            stmt,
2826            sql: sql.to_string(),
2827        })
2828    }
2829
2830    /// v7.17.0 Phase 3.P0-66 — describe a SQL string without
2831    /// executing. Returns `(parameter_oid_count, output_columns)`
2832    /// where `output_columns` is empty for non-SELECT statements
2833    /// or for SELECT shapes the describe planner can't resolve
2834    /// (JOIN / subquery / unknown table). Wraps
2835    /// `Engine::describe_prepared` so the spg-sqlx bridge can
2836    /// surface PG-shape Describe replies for
2837    /// `sqlx::query!()` compile-time validation.
2838    ///
2839    /// # Errors
2840    /// Propagates parse errors from the underlying prepare path.
2841    pub fn describe(&mut self, sql: &str) -> Result<(Vec<u32>, Vec<ColumnSchema>), EngineError> {
2842        let stmt = self
2843            .engine
2844            .prepare_cached(sql)
2845            .map_err(EngineError::Parse)?;
2846        Ok(self.engine.describe_prepared(&stmt))
2847    }
2848
2849    /// v7.16.0 — execute a prepared statement with bound
2850    /// parameters. Mirrors `Engine::execute_prepared`: clones
2851    /// the AST, substitutes `$1..$N` → `params[0..N-1]`, runs.
2852    ///
2853    /// Persistence (WAL fsync + auto-checkpoint) follows the
2854    /// same rules as `execute(sql)`: mutating statements get a
2855    /// WAL record AFTER the in-memory exec succeeds. The WAL
2856    /// record carries the substituted, bind-final SQL, so
2857    /// replay reconstructs the same row state without needing
2858    /// the original prepared `Statement` to still be alive.
2859    ///
2860    /// # Errors
2861    /// Propagates engine errors. Param arity mismatch surfaces
2862    /// as `EvalError::PlaceholderOutOfRange`.
2863    pub fn execute_prepared(
2864        &mut self,
2865        stmt: &Statement,
2866        params: &[Value<'static>],
2867    ) -> Result<QueryResult, EngineError> {
2868        let (result, ticket) = self.execute_prepared_buffered(stmt, params)?;
2869        if let Some(t) = ticket {
2870            t.wait()?;
2871        }
2872        Ok(result)
2873    }
2874
2875    /// v7.20 P2 — group-commit variant of
2876    /// [`Database::execute_prepared`]. Same contract as
2877    /// [`Database::execute_buffered`]: mutation + enqueue happen
2878    /// here; the caller waits on the ticket AFTER releasing
2879    /// whatever lock guards this `Database`.
2880    ///
2881    /// # Errors
2882    /// Engine errors propagate unchanged; inline auto-checkpoint
2883    /// may surface IO errors.
2884    pub fn execute_prepared_buffered(
2885        &mut self,
2886        stmt: &Statement,
2887        params: &[Value<'static>],
2888    ) -> Result<(QueryResult, Option<WalTicket>), EngineError> {
2889        let result = self.engine.execute_prepared(stmt.stmt.clone(), params)?;
2890        let modified = matches!(
2891            &result,
2892            QueryResult::CommandOk {
2893                modified_catalog: true,
2894                ..
2895            }
2896        );
2897        // WAL persistence on the bind-final SQL. Build the
2898        // canonical Display form by re-printing the
2899        // placeholder-substituted statement (cheap — the AST
2900        // is already in hand from execute_prepared's internal
2901        // clone) so replay's path is identical to the
2902        // simple-query path. v7.21: also when a transaction is
2903        // open — in-tx mutations report `modified_catalog: false`
2904        // but must reach the tx WAL buffer (see `wal_after_ok`).
2905        let mut ticket = None;
2906        if self.persistence.is_some()
2907            && (modified
2908                || (self.tx_wal.is_some() && !sql_is_read_only(&stmt.sql))
2909                || tx_control_kind(&stmt.sql).is_some())
2910        {
2911            let mut wal_stmt = stmt.stmt.clone();
2912            crate::wal_render_with_params(&mut wal_stmt, params);
2913            let canonical = format!("{wal_stmt}");
2914            ticket = self.wal_after_ok(&canonical, modified)?;
2915        }
2916        Ok((result, ticket))
2917    }
2918
2919    /// v7.16.0 — run a prepared SELECT with bound params and
2920    /// return rows as `Vec<Vec<Value>>`, matching `query()`
2921    /// shape. SELECTs are read-only so this never writes the
2922    /// WAL.
2923    ///
2924    /// # Errors
2925    /// Returns `Unsupported` if the prepared statement isn't a
2926    /// SELECT (use `execute_prepared` for DML/DDL).
2927    pub fn query_prepared(
2928        &mut self,
2929        stmt: &Statement,
2930        params: &[Value<'static>],
2931    ) -> Result<Vec<Vec<Value<'static>>>, EngineError> {
2932        match self.engine.execute_prepared(stmt.stmt.clone(), params)? {
2933            QueryResult::Rows { rows, .. } => Ok(rows.into_iter().map(|r| r.values).collect()),
2934            QueryResult::CommandOk { .. } => Err(EngineError::Unsupported(
2935                "query_prepared() expects a SELECT — use execute_prepared() for DML/DDL".into(),
2936            )),
2937            _ => Err(EngineError::Unsupported(
2938                "query_prepared() expects a SELECT — use execute_prepared() for DML/DDL".into(),
2939            )),
2940        }
2941    }
2942
2943    /// v7.18 — parse + plan a SQL string against a
2944    /// `CatalogSnapshot`. Mirror of [`Database::prepare`] for the
2945    /// readonly fan-out path: no writer lock taken, no WAL write,
2946    /// no plan-cache mutation. Static-on-`Self` so callers can
2947    /// dispatch against a snapshot without an `&mut Database`
2948    /// borrow — `AsyncReadHandle::prepare` in spg-embedded-tokio
2949    /// is the load-bearing consumer.
2950    ///
2951    /// # Errors
2952    /// Propagates `EngineError::Parse` from the parser.
2953    pub fn prepare_on_snapshot(
2954        snapshot: &CatalogSnapshot,
2955        sql: &str,
2956    ) -> Result<Statement, EngineError> {
2957        let stmt =
2958            spg_engine::Engine::prepare_on_snapshot(snapshot, sql).map_err(EngineError::Parse)?;
2959        Ok(Statement {
2960            stmt,
2961            sql: sql.to_string(),
2962        })
2963    }
2964
2965    /// v7.18 — execute a prepared `Statement` against a
2966    /// `CatalogSnapshot` with bound params. Mirror of
2967    /// [`Database::execute_prepared`] on the readonly path:
2968    /// writes / DDL hit `EngineError::WriteRequired`. No WAL
2969    /// write, no writer lock, multiple snapshots can run
2970    /// concurrently — the snapshot is immutable from prepare time.
2971    ///
2972    /// # Errors
2973    /// Surfaces `EngineError::WriteRequired` for non-readonly
2974    /// statements; propagates other engine errors.
2975    pub fn execute_prepared_on_snapshot(
2976        snapshot: &CatalogSnapshot,
2977        stmt: &Statement,
2978        params: &[Value<'static>],
2979    ) -> Result<QueryResult, EngineError> {
2980        spg_engine::Engine::execute_readonly_prepared_on_snapshot(
2981            snapshot,
2982            stmt.stmt.clone(),
2983            params,
2984        )
2985    }
2986
2987    /// v7.28 (round-22) — deadline-bounded variant of
2988    /// [`Database::execute_prepared_on_snapshot`]. Returns
2989    /// `EngineError::Cancelled` once the budget elapses; the
2990    /// sqlx driver uses this to keep readonly-INLINE execution
2991    /// from monopolising the caller's async runtime (four slow
2992    /// inbox queries saturated mailrs's whole tokio pool) and
2993    /// re-runs over the blocking pool on timeout.
2994    ///
2995    /// # Errors
2996    /// `EngineError::Cancelled` on budget expiry; engine errors
2997    /// otherwise.
2998    pub fn execute_prepared_on_snapshot_with_budget(
2999        snapshot: &CatalogSnapshot,
3000        stmt: &Statement,
3001        params: &[Value<'static>],
3002        budget_us: u64,
3003    ) -> Result<QueryResult, EngineError> {
3004        fn mono_now_us() -> u64 {
3005            use std::time::{SystemTime, UNIX_EPOCH};
3006            // Monotonic enough for a per-call relative budget: the
3007            // engine only compares (now - start) against the budget
3008            // within one call.
3009            SystemTime::now()
3010                .duration_since(UNIX_EPOCH)
3011                .map(|d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX))
3012                .unwrap_or(0)
3013        }
3014        let deadline = mono_now_us().saturating_add(budget_us);
3015        let token = spg_engine::CancelToken::none().with_deadline(mono_now_us, deadline);
3016        spg_engine::Engine::execute_readonly_prepared_on_snapshot_with_cancel(
3017            snapshot,
3018            stmt.stmt.clone(),
3019            params,
3020            token,
3021        )
3022    }
3023
3024    /// v7.18 — describe a SQL string against a
3025    /// `CatalogSnapshot`. Mirror of [`Database::describe`] on
3026    /// the readonly path. Pure function on the snapshot's
3027    /// catalog; safe to call from any thread.
3028    ///
3029    /// # Errors
3030    /// Propagates `EngineError::Parse` from the parser.
3031    pub fn describe_on_snapshot(
3032        snapshot: &CatalogSnapshot,
3033        sql: &str,
3034    ) -> Result<(Vec<u32>, Vec<ColumnSchema>), EngineError> {
3035        let stmt =
3036            spg_engine::Engine::prepare_on_snapshot(snapshot, sql).map_err(EngineError::Parse)?;
3037        Ok(spg_engine::Engine::describe_prepared_on_snapshot(
3038            snapshot, &stmt,
3039        ))
3040    }
3041
3042    /// v7.21 (round-12 polish) — run a multi-statement SQL script
3043    /// with PG simple-query semantics: the statements execute in
3044    /// order inside ONE implicit transaction, so a mid-script error
3045    /// rolls back the whole script (PG wraps every simple-query
3046    /// message in an implicit transaction). Three exceptions, all
3047    /// PG-faithful:
3048    ///
3049    /// - a script that carries its OWN transaction control
3050    ///   (BEGIN / COMMIT / …) runs statement-by-statement — the
3051    ///   script owns its boundaries;
3052    /// - a script run while the caller already has a transaction
3053    ///   open joins that transaction (no nested BEGIN), and the
3054    ///   caller's COMMIT / ROLLBACK decides its fate;
3055    /// - a single-statement script is plain auto-commit.
3056    ///
3057    /// Returns one `QueryResult` per executed statement. This is the
3058    /// engine behind `sqlx::raw_sql` (mailrs feeds whole
3059    /// `init-schema.sql` files through it) and `spgctl import`.
3060    ///
3061    /// # Errors
3062    /// The first failing statement's error propagates after the
3063    /// implicit ROLLBACK; nothing from the script remains applied.
3064    pub fn execute_script(&mut self, sql: &str) -> Result<Vec<QueryResult>, EngineError> {
3065        let stmts = split_statements(sql);
3066        let script_owns_tx = stmts.iter().any(|s| tx_control_kind(s).is_some());
3067        let wrap = stmts.len() > 1 && !script_owns_tx && !self.engine.in_transaction();
3068        if !wrap {
3069            let mut out = Vec::with_capacity(stmts.len());
3070            for stmt in &stmts {
3071                out.push(self.execute_dump_statement(stmt)?);
3072            }
3073            return Ok(out);
3074        }
3075        self.execute("BEGIN")?;
3076        let mut out = Vec::with_capacity(stmts.len());
3077        for stmt in &stmts {
3078            match self.execute_dump_statement(stmt) {
3079                Ok(r) => out.push(r),
3080                Err(e) => {
3081                    // Best-effort rollback; surface the script error.
3082                    let _ = self.execute("ROLLBACK");
3083                    return Err(e);
3084                }
3085            }
3086        }
3087        self.execute("COMMIT")?;
3088        Ok(out)
3089    }
3090
3091    /// v7.22 (round-13 T2) — execute one `split_statements` chunk,
3092    /// lowering a `COPY … FROM stdin;` block (statement + its data
3093    /// lines, as one chunk) to per-row INSERTs through the shared
3094    /// `spg_engine::copy` helpers. Default-format pg_dump emits
3095    /// COPY blocks, so the zero-change import promise needs this on
3096    /// the embed path; non-COPY statements pass straight through to
3097    /// [`Self::execute`]. Public so `spgctl import` can keep its
3098    /// per-statement error indexing while sharing the lowering.
3099    ///
3100    /// # Errors
3101    /// Engine errors propagate; for COPY the failing row's INSERT
3102    /// error carries the synthesized statement context.
3103    pub fn execute_dump_statement(&mut self, stmt: &str) -> Result<QueryResult, EngineError> {
3104        // Strip pg_dump's `-- Data for Name: …;` banner (it carries
3105        // semicolons of its own) before splitting head from data.
3106        let stmt_clean = strip_leading_sql_noise(stmt);
3107        let head_is_copy = stmt_clean
3108            .get(..4)
3109            .is_some_and(|p| p.eq_ignore_ascii_case("copy"));
3110        if head_is_copy
3111            && let Some((head, data)) = stmt_clean.split_once(';')
3112            && let Some(spec) = spg_engine::copy::parse_copy_from_stdin_head(head)
3113        {
3114            let mut affected: usize = 0;
3115            for line in data.lines() {
3116                // Empty fragments only occur at the chunk boundary
3117                // (the remainder of the COPY line right after `;`);
3118                // data rows are whole non-empty lines.
3119                let line = line.strip_suffix('\r').unwrap_or(line);
3120                if line.is_empty() {
3121                    continue;
3122                }
3123                let values = spg_engine::copy::decode_copy_text_row(line);
3124                let insert = spg_engine::copy::build_copy_insert(
3125                    &spec.table,
3126                    spec.columns.as_deref(),
3127                    &values,
3128                );
3129                match self.execute(&insert)? {
3130                    QueryResult::CommandOk { affected: n, .. } => affected += n,
3131                    _ => affected += 1,
3132                }
3133            }
3134            return Ok(QueryResult::CommandOk {
3135                affected,
3136                modified_catalog: false,
3137            });
3138        }
3139        self.execute(stmt)
3140    }
3141
3142    /// v7.2.0 — run `body` inside an implicit `BEGIN` /
3143    /// `COMMIT` pair. The body receives `&mut Database` so it
3144    /// can `execute()` / `query()` like any other code path;
3145    /// the only difference is that every write in the body
3146    /// lands inside one transaction, and a returned `Err` from
3147    /// the body triggers `ROLLBACK` before the error propagates.
3148    ///
3149    /// Nested calls are not supported — SPG's transaction
3150    /// model is single-writer with explicit `BEGIN` /
3151    /// `COMMIT` / `ROLLBACK`, and a nested `with_transaction`
3152    /// would hit `EngineError::Unsupported("nested
3153    /// transaction")` at the inner `BEGIN`.
3154    pub fn with_transaction<R, F>(&mut self, body: F) -> Result<R, EngineError>
3155    where
3156        F: FnOnce(&mut Self) -> Result<R, EngineError>,
3157    {
3158        self.execute("BEGIN")?;
3159        match body(self) {
3160            Ok(value) => {
3161                self.execute("COMMIT")?;
3162                Ok(value)
3163            }
3164            Err(e) => {
3165                // Best-effort rollback. If ROLLBACK itself
3166                // fails (rare — the engine reports it via
3167                // `Unsupported` only when there's no active
3168                // TX, which can't happen here) we surface the
3169                // original body error, not the rollback error.
3170                let _ = self.execute("ROLLBACK");
3171                Err(e)
3172            }
3173        }
3174    }
3175}
3176
3177impl Default for Database {
3178    fn default() -> Self {
3179        Self::open_in_memory()
3180    }
3181}
3182
3183/// v7.7.5 — observability snapshot returned by
3184/// [`Database::metrics`]. Plain data, no allocations beyond
3185/// what the struct itself takes; cheap to construct and
3186/// cheap to serialise.
3187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3188#[non_exhaustive]
3189pub struct EmbeddedMetrics {
3190    /// Total live row count across every user table (hot
3191    /// tier only — cold-tier rows live in segment files).
3192    pub hot_rows: u64,
3193    /// Sum of `Table::hot_bytes` across every user table.
3194    /// Tracks against the freezer's `hot_tier_bytes` budget.
3195    pub hot_bytes: u64,
3196    /// Number of cold-tier segments registered in the catalog.
3197    /// Includes tombstoned slots (segments retired by
3198    /// compaction whose disk file may still be on disk).
3199    pub cold_segments: u64,
3200    /// User-table count (excludes any future engine-managed
3201    /// internal tables).
3202    pub tables: u64,
3203    /// WAL size at last `execute()` / `checkpoint()`. Zero
3204    /// when the database is in-memory.
3205    pub wal_bytes: u64,
3206    /// `true` when the database was opened with `open_path` —
3207    /// i.e. WAL + checkpoint persistence is active.
3208    pub persistent: bool,
3209}
3210
3211/// v7.2.1 — handle returned by `spawn_background_freezer`.
3212/// Drop signals the worker thread to wind down + joins it,
3213/// so a `Database` (or its shared `Arc<Mutex<Database>>`)
3214/// can safely drop after the handle does.
3215#[must_use = "the background freezer keeps running until this handle is dropped"]
3216#[derive(Debug)]
3217pub struct FreezerHandle {
3218    shutdown: Arc<AtomicBool>,
3219    join: Option<JoinHandle<()>>,
3220}
3221
3222impl FreezerHandle {
3223    /// v7.2.1 — request the worker stop + join. Idempotent;
3224    /// safe to call from `Drop` (which also calls it).
3225    pub fn stop(&mut self) {
3226        self.shutdown.store(true, Ordering::Release);
3227        if let Some(h) = self.join.take() {
3228            let _ = h.join();
3229        }
3230    }
3231}
3232
3233impl Drop for FreezerHandle {
3234    fn drop(&mut self) {
3235        self.stop();
3236    }
3237}
3238
3239/// v7.2.1 — knobs for `Database::spawn_background_freezer`.
3240#[derive(Debug, Clone)]
3241pub struct FreezerOptions {
3242    /// Tick interval. Worker wakes every `tick`, checks the
3243    /// catalog's `hot_tier_bytes`, and freezes if over budget.
3244    pub tick: Duration,
3245    /// Hot-tier byte budget. Exceeded → next tick freezes the
3246    /// largest table's oldest `batch_rows` rows into a new
3247    /// cold segment.
3248    pub hot_tier_bytes: u64,
3249    /// Max rows the freezer demotes per fire.
3250    pub batch_rows: usize,
3251    /// v7.7.4 — auto-compact threshold. When the catalog has
3252    /// at least this many cold segments across all tables, the
3253    /// freezer fires a compaction pass after its next freeze.
3254    /// Set to `usize::MAX` to disable auto-compact entirely;
3255    /// the default is `64`, matching the `spg-server` operating
3256    /// point for SPG_COLD_COMPACT_SEGMENT_THRESHOLD.
3257    pub compact_when_segments_exceed: usize,
3258    /// v7.7.4 — target segment size for compaction merges,
3259    /// in bytes. Default 64 MiB, mirroring `spg-server`. Small
3260    /// segments below this size are merge candidates;
3261    /// segments at or above stay untouched.
3262    pub compact_target_bytes: u64,
3263}
3264
3265impl Default for FreezerOptions {
3266    fn default() -> Self {
3267        // Match the `spg-server` freezer's default operating
3268        // point (SPG_HOT_TIER_BYTES = 4 GiB, batch 1000 rows,
3269        // tick every 1 s) so embedded behaviour is predictable
3270        // for operators familiar with the server.
3271        Self {
3272            tick: Duration::from_secs(1),
3273            hot_tier_bytes: 4 * 1024 * 1024 * 1024,
3274            batch_rows: 1000,
3275            compact_when_segments_exceed: 64,
3276            compact_target_bytes: 64 * 1024 * 1024,
3277        }
3278    }
3279}
3280
3281impl Database {
3282    /// v7.7.4 — observe the catalog's cold-segment count.
3283    /// Useful for tests + dashboards that want to verify
3284    /// auto-compaction is firing.
3285    #[must_use]
3286    pub fn cold_segment_count(&self) -> usize {
3287        self.engine.catalog().cold_segment_count()
3288    }
3289
3290    /// v7.7.5 — observability snapshot. Returns a point-in-time
3291    /// view of the engine + persistence counters. Cheap (no
3292    /// locks beyond the existing `&self` borrow), so safe to
3293    /// call from a hot metrics-scrape path.
3294    ///
3295    /// Fields mirror the operational dashboard
3296    /// [`spg-server`](https://crates.io/crates/spg-server) exposes,
3297    /// minus the network counters that don't apply to embedded.
3298    #[must_use]
3299    pub fn metrics(&self) -> EmbeddedMetrics {
3300        let cat = self.engine.catalog();
3301        let mut hot_rows: u64 = 0;
3302        let mut hot_bytes: u64 = 0;
3303        for name in cat.table_names() {
3304            if let Some(t) = cat.get(&name) {
3305                hot_rows = hot_rows.saturating_add(t.row_count() as u64);
3306                hot_bytes = hot_bytes.saturating_add(t.hot_bytes());
3307            }
3308        }
3309        let (wal_bytes, persistent) = match &self.persistence {
3310            Some(p) => (p.wal.written_len(), true),
3311            None => (0, false),
3312        };
3313        EmbeddedMetrics {
3314            hot_rows,
3315            hot_bytes,
3316            cold_segments: cat.cold_segment_count() as u64,
3317            tables: cat.table_count() as u64,
3318            wal_bytes,
3319            persistent,
3320        }
3321    }
3322
3323    /// v7.2.1 — spawn a background thread that periodically
3324    /// runs `freeze_oldest_to_cold` when the catalog-wide hot
3325    /// tier exceeds `opts.hot_tier_bytes`. The `Arc<Mutex<_>>`
3326    /// pattern matches the v7.2 sharing story: callers wrap
3327    /// their `Database` in `Arc::new(Mutex::new(db))` once,
3328    /// then clone the Arc for the worker + for foreground
3329    /// access. Return value is a handle whose `Drop` joins the
3330    /// worker.
3331    ///
3332    /// Picks the freeze target the same way `spg-server`'s
3333    /// freezer does: largest-`hot_bytes` user table with at
3334    /// least one BTree integer-PK index. Tables without a
3335    /// freezable index are skipped silently.
3336    pub fn spawn_background_freezer(
3337        db: Arc<Mutex<Database>>,
3338        opts: FreezerOptions,
3339    ) -> FreezerHandle {
3340        let shutdown = Arc::new(AtomicBool::new(false));
3341        let shutdown_for_thread = Arc::clone(&shutdown);
3342        let join = thread::Builder::new()
3343            .name("spg-embedded-freezer".into())
3344            .spawn(move || {
3345                background_freezer_loop(db, opts, shutdown_for_thread);
3346            })
3347            .expect("spawn background freezer thread");
3348        FreezerHandle {
3349            shutdown,
3350            join: Some(join),
3351        }
3352    }
3353}
3354
3355/// v7.2.1 — the freezer's main loop, factored out so the
3356/// `Database::spawn_background_freezer` path stays readable.
3357fn background_freezer_loop(
3358    db: Arc<Mutex<Database>>,
3359    opts: FreezerOptions,
3360    shutdown: Arc<AtomicBool>,
3361) {
3362    // Sleep in short slices so a shutdown request resolves
3363    // quickly (vs sleeping the full tick).
3364    let slice = Duration::from_millis(50.min(opts.tick.as_millis() as u64));
3365    let mut last_tick = std::time::Instant::now();
3366    loop {
3367        if shutdown.load(Ordering::Acquire) {
3368            return;
3369        }
3370        thread::sleep(slice);
3371        if last_tick.elapsed() < opts.tick {
3372            continue;
3373        }
3374        last_tick = std::time::Instant::now();
3375        let Ok(mut guard) = db.lock() else {
3376            return;
3377        };
3378        if guard.engine.catalog().hot_tier_bytes() <= opts.hot_tier_bytes {
3379            continue;
3380        }
3381        let Some((table, index)) = pick_freeze_target(&guard) else {
3382            continue;
3383        };
3384        let row_count = guard
3385            .engine
3386            .catalog()
3387            .get(&table)
3388            .map_or(0, spg_storage::Table::row_count);
3389        let to_freeze = opts.batch_rows.min(row_count);
3390        if to_freeze == 0 {
3391            continue;
3392        }
3393        if let Err(e) = guard.freeze_oldest_to_cold(&table, &index, to_freeze) {
3394            eprintln!("spg-embedded: background freeze on {table}.{index} failed: {e:?}");
3395            continue;
3396        }
3397        // v7.7.4 — auto-compact. If the catalog now carries
3398        // more cold segments than the configured threshold,
3399        // run a single compaction pass. Failures are reported
3400        // but don't kill the loop; the next tick will retry.
3401        let count = guard.engine.catalog().cold_segment_count();
3402        if count > opts.compact_when_segments_exceed {
3403            if let Err(e) = guard
3404                .engine
3405                .compact_cold_segments_with_target(opts.compact_target_bytes)
3406            {
3407                eprintln!(
3408                    "spg-embedded: background compact failed (segments={count}, \
3409                     threshold={}): {e:?}",
3410                    opts.compact_when_segments_exceed,
3411                );
3412            }
3413        }
3414    }
3415}
3416
3417/// v7.2.1 — pick the highest-`hot_bytes` user table with a
3418/// BTree integer-PK index. Returns `(table, index_name)` so the
3419/// caller can dispatch through `freeze_oldest_to_cold`.
3420fn pick_freeze_target(db: &Database) -> Option<(String, String)> {
3421    let cat = db.engine.catalog();
3422    let mut best: Option<(String, String, u64)> = None;
3423    for name in cat.table_names() {
3424        let Some(t) = cat.get(&name) else { continue };
3425        if t.row_count() == 0 {
3426            continue;
3427        }
3428        let cols = &t.schema().columns;
3429        let Some(idx) = t.indices().iter().find(|i| {
3430            matches!(i.kind, spg_storage::IndexKind::BTree(_))
3431                && i.column_position < cols.len()
3432                && matches!(
3433                    cols[i.column_position].ty,
3434                    spg_storage::DataType::SmallInt
3435                        | spg_storage::DataType::Int
3436                        | spg_storage::DataType::BigInt
3437                )
3438        }) else {
3439            continue;
3440        };
3441        let hot = t.hot_bytes();
3442        match best {
3443            None => best = Some((name, idx.name.clone(), hot)),
3444            Some((_, _, best_hot)) if hot > best_hot => {
3445                best = Some((name, idx.name.clone(), hot));
3446            }
3447            _ => {}
3448        }
3449    }
3450    best.map(|(t, i, _)| (t, i))
3451}
3452
3453/// v7.7.6 — replay the first `to_seq` records of the WAL at
3454/// `wal_path` into a fresh engine and write the resulting
3455/// catalog snapshot to `out_db_path`. Same semantics as
3456/// `spg revert --wal … --to-seq N --out …` from the CLI:
3457///
3458///   - `to_seq == 0` → snapshot is the empty catalog
3459///   - WAL records beyond `to_seq` are not applied
3460///   - durability-checkpoint markers (v3 type 0x02) are
3461///     consumed without counting against the budget
3462///
3463/// Returns the number of statements actually applied
3464/// (`≤ to_seq`). The output snapshot is byte-identical to
3465/// what `Database::open_path(out_db_path)` would consume on
3466/// a subsequent open.
3467///
3468/// This is the "rewind" operator for an embedded database
3469/// that has been corrupted by a poison statement or a
3470/// half-applied migration. Pair with `cold_segment_paths`
3471/// preservation if your cold-tier files are still on disk.
3472///
3473/// # Errors
3474///
3475/// - `wal_path` unreadable or truncated mid-record
3476/// - WAL record decodes to invalid UTF-8 SQL
3477/// - WAL record's SQL is rejected by the engine
3478/// - `out_db_path` unwritable
3479pub fn revert_wal_to_seq(
3480    wal_path: impl AsRef<Path>,
3481    to_seq: u64,
3482    out_db_path: impl AsRef<Path>,
3483) -> Result<u64, EngineError> {
3484    // v7.19 — accept either a single-file legacy WAL (v7.18 and
3485    // earlier layout) or a chunked WAL directory (v7.19+). For a
3486    // directory, concatenate every `.wal` chunk in sorted order
3487    // — the same order open_path replays them in — so revert
3488    // sees the full record stream.
3489    let path = wal_path.as_ref();
3490    let wal_bytes = if path.is_dir() {
3491        let mut combined = Vec::new();
3492        let chunks = sorted_wal_chunks(path).map_err(io_err)?;
3493        for chunk in chunks {
3494            let bytes = std::fs::read(&chunk).map_err(io_err)?;
3495            combined.extend_from_slice(&bytes);
3496        }
3497        combined
3498    } else {
3499        std::fs::read(path).map_err(io_err)?
3500    };
3501    // v7.37.8 — switched from `decode_wal_record` (V1-V3 SQL-only) to
3502    // `parse_wal_records` + per-type dispatch, mirroring
3503    // `replay_wal_filtered`. The pre-v7.37.8 path silently mis-parsed
3504    // V4/V5 framed records as "truncated" because their length
3505    // headers carry the V2_SENTINEL / V3_FLAG bits that
3506    // `decode_wal_record`'s legacy header-decode never strips. v7.37.8
3507    // flips `SPG_WAL_ROW_REDO` default ON, so freshly written WALs
3508    // are V5 ROW_REDO; the PITR utility must understand them too.
3509    let mut engine = Engine::new();
3510    let mut applied = 0u64;
3511    let records = parse_wal_records(&wal_bytes).map_err(|m| {
3512        EngineError::Storage(spg_storage::StorageError::Corrupt(m))
3513    })?;
3514    for r in &records {
3515        if applied >= to_seq {
3516            break;
3517        }
3518        // Markers don't count toward the seq budget — they're metadata.
3519        if r.type_byte == WAL_V3_TYPE_DURABILITY_CHECKPOINT
3520            || r.type_byte == WAL_V4_TYPE_CHECKPOINT_MARKER
3521        {
3522            continue;
3523        }
3524        if r.type_byte == WAL_V5_TYPE_ROW_REDO {
3525            let changes = spg_storage::decode_redo_log(r.sql).map_err(|e| {
3526                EngineError::Storage(spg_storage::StorageError::Corrupt(format!(
3527                    "PITR: redo decode at offset {}: {e:?}",
3528                    r.offset
3529                )))
3530            })?;
3531            engine.apply_redo(&changes)?;
3532            applied += 1;
3533            continue;
3534        }
3535        // V1-V3 (legacy SQL) and V4 AUTO_COMMIT_SQL / TX_COMMIT_SQL —
3536        // re-execute the SQL payload.
3537        let sql = core::str::from_utf8(r.sql).map_err(|e| {
3538            EngineError::Storage(spg_storage::StorageError::Corrupt(format!(
3539                "PITR: WAL record at offset {}: non-UTF-8 SQL: {e}",
3540                r.offset
3541            )))
3542        })?;
3543        for stmt in split_statements(sql) {
3544            engine.execute(stmt)?;
3545        }
3546        applied += 1;
3547    }
3548    let snapshot = engine.snapshot();
3549    std::fs::write(out_db_path.as_ref(), &snapshot).map_err(io_err)?;
3550    Ok(applied)
3551}
3552
3553/// v7.7.6 — decode one WAL record from a byte tail. Returns
3554/// `(sql_bytes, header_plus_payload_len)`. Handles the three
3555/// on-disk formats (v1 / v2 / v3) the same way the CLI
3556/// `decode_one_record` and the engine's `replay_wal_bytes`
3557/// do. CRCs are not re-validated; the caller's intent is
3558/// "apply", not "validate".
3559fn decode_wal_record(tail: &[u8]) -> Result<(Vec<u8>, usize), EngineError> {
3560    if tail.len() < 4 {
3561        return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3562            format!("WAL truncated record: {} < 4 header bytes", tail.len()),
3563        )));
3564    }
3565    let raw_len = u32::from_le_bytes(tail[..4].try_into().unwrap());
3566    let is_v2 = raw_len & WAL_V2_SENTINEL != 0;
3567    let is_v3 = is_v2 && (raw_len & WAL_V3_FLAG != 0);
3568    let len_mask = if is_v3 {
3569        !(WAL_V2_SENTINEL | WAL_V3_FLAG)
3570    } else {
3571        !WAL_V2_SENTINEL
3572    };
3573    let rec_len = (raw_len & len_mask) as usize;
3574    let header_len = if is_v3 {
3575        9
3576    } else if is_v2 {
3577        8
3578    } else {
3579        4
3580    };
3581    if tail.len() < header_len + rec_len {
3582        return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3583            format!(
3584                "WAL truncated record: header+payload {} > available {}",
3585                header_len + rec_len,
3586                tail.len()
3587            ),
3588        )));
3589    }
3590    if is_v3 {
3591        let type_byte = tail[8];
3592        // v3 type 0x01 = auto_commit_sql (payload = SQL).
3593        // v3 type 0x02 = durability marker (no SQL to apply).
3594        // v4 type 0x10 = auto_commit_sql with 16-byte (lsn, ts)
3595        //                prefix between type and SQL — strip
3596        //                the prefix so the caller still sees raw
3597        //                SQL bytes.
3598        // Anything else is unknown.
3599        if type_byte == WAL_V3_TYPE_AUTO_COMMIT_SQL {
3600            let payload = &tail[header_len..header_len + rec_len];
3601            return Ok((payload.to_vec(), header_len + rec_len));
3602        }
3603        if type_byte == WAL_V4_TYPE_AUTO_COMMIT_SQL || type_byte == WAL_V4_TYPE_TX_COMMIT_SQL {
3604            let v4_total = header_len + WAL_V4_EXTRA_HEADER + rec_len;
3605            if tail.len() < v4_total {
3606                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3607                    format!(
3608                        "WAL truncated v4 record: header+payload {v4_total} > available {}",
3609                        tail.len()
3610                    ),
3611                )));
3612            }
3613            let sql_start = header_len + WAL_V4_EXTRA_HEADER;
3614            let sql_bytes = tail[sql_start..sql_start + rec_len].to_vec();
3615            return Ok((sql_bytes, v4_total));
3616        }
3617        // Caller treats empty payload as a skip-marker.
3618        return Ok((Vec::new(), header_len + rec_len));
3619    }
3620    let payload = &tail[header_len..header_len + rec_len];
3621    Ok((payload.to_vec(), header_len + rec_len))
3622}
3623
3624impl Drop for Database {
3625    fn drop(&mut self) {
3626        // v7.1 — best-effort final checkpoint when a persistent
3627        // Database leaves scope. Failures here go to stderr so
3628        // operators see them, but Drop can't propagate errors —
3629        // the WAL itself is already durable, so a checkpoint
3630        // miss only means the next boot replays a few more
3631        // records than strictly necessary.
3632        if self.persistence.is_some() {
3633            if let Err(e) = self.checkpoint() {
3634                eprintln!(
3635                    "spg-embedded: final checkpoint on Drop failed: {e:?} \
3636                     (WAL is intact; next open_path will replay)"
3637                );
3638            }
3639        }
3640        // v7.19 P3 / v7.20 — signal the retention + flusher
3641        // threads to exit, then wait for them. Done BEFORE the
3642        // lock release so background threads don't outlive the
3643        // database handle. The flusher drains the pending batch
3644        // on its way out (final flush_now in the thread body),
3645        // so `SPG_SYNCHRONOUS_COMMIT=off` never loses confirmed
3646        // commits across a clean shutdown.
3647        if let Some(ctx) = self.persistence.as_mut() {
3648            if let Some(shutdown) = ctx.retention_shutdown.take() {
3649                shutdown.store(true, Ordering::SeqCst);
3650            }
3651            if let Some(handle) = ctx.retention_thread.take() {
3652                let _ = handle.join();
3653            }
3654            if let Some(shutdown) = ctx.flusher_shutdown.take() {
3655                shutdown.store(true, Ordering::SeqCst);
3656            }
3657            if let Some(handle) = ctx.flusher_thread.take() {
3658                let _ = handle.join();
3659            }
3660            // CoW-2 (v7.34) — final checkpoint above left the worker
3661            // idle; explicitly drop it here so its shutdown signal +
3662            // thread join happens with a deterministic ordering (before
3663            // the lock release / persistence drop), not whenever Rust
3664            // happens to drop the PersistenceCtx fields.
3665            ctx.checkpoint_worker = None;
3666        }
3667        // v7.17.0 Phase 6.2 — release the cross-process lock on
3668        // clean shutdown. Failure is logged but never panics;
3669        // the operator can clear a stale lock via
3670        // `Database::force_unlock` if a crash kept the
3671        // directory around.
3672        if let Some(ctx) = &self.persistence
3673            && ctx.lock_path.exists()
3674        {
3675            // remove_dir_all: the lock dir carries the owner-pid
3676            // record since round-12.
3677            if let Err(e) = std::fs::remove_dir_all(&ctx.lock_path) {
3678                eprintln!(
3679                    "spg-embedded: lock release on Drop failed for {}: {e:?}",
3680                    ctx.lock_path.display()
3681                );
3682            }
3683        }
3684    }
3685}
3686
3687impl Database {
3688    /// v7.17.0 Phase 6.2 — clear a stale cross-process lock.
3689    /// Use when a previous process crashed mid-session and
3690    /// left `<db_path>.lock` behind. Operators should confirm
3691    /// no other process is currently using the database before
3692    /// calling this — SPG cannot fingerprint stale-vs-live
3693    /// without a libc dep, which would violate spg-embedded's
3694    /// zero-deps charter.
3695    pub fn force_unlock(db_path: impl AsRef<Path>) -> Result<(), EngineError> {
3696        let lock_path = {
3697            let mut p = db_path.as_ref().to_path_buf();
3698            let name = p
3699                .file_name()
3700                .map(|n| {
3701                    let mut s = n.to_os_string();
3702                    s.push(".lock");
3703                    s
3704                })
3705                .unwrap_or_else(|| std::ffi::OsString::from(".lock"));
3706            p.set_file_name(name);
3707            p
3708        };
3709        // v7.37.5 (mailrs crash-recovery Ask 2) — also clear the
3710        // in-process registry entry for this lock_path. The operator
3711        // calling `force_unlock` asserts "no one is using this catalog;
3712        // nuke the lock"; the in-process registry would otherwise
3713        // keep an in-flight sibling `Database::open_path` task
3714        // registered, and a same-process retry post-force_unlock
3715        // would refuse honestly with the Ask 1 in-flight error
3716        // even though the operator just declared the catalog free.
3717        // Drop the registry entry before the disk lock so retries
3718        // see a consistent "free" state. The orphaned in-flight
3719        // task, if any, will surface its own error when it tries
3720        // to release the now-vanished lock dir; that's the
3721        // single-instance contract `force_unlock` documents.
3722        {
3723            let mut set = active_open_paths()
3724                .lock()
3725                .unwrap_or_else(|e| e.into_inner());
3726            set.remove(&lock_path);
3727        }
3728        if !lock_path.exists() {
3729            return Ok(());
3730        }
3731        std::fs::remove_dir_all(&lock_path).map_err(io_err)
3732    }
3733}
3734
3735/// v7.1 — turn a `std::io::Error` into the workspace's
3736/// `EngineError` shape. `EngineError::Storage(Corrupt(_))` is
3737/// the closest existing variant — io failures during boot or
3738/// during a WAL append surface as a storage-layer fault to
3739/// callers, which keeps the public error enum unchanged.
3740fn io_err(e: std::io::Error) -> EngineError {
3741    EngineError::Storage(spg_storage::StorageError::Corrupt(format!("io: {e}")))
3742}
3743
3744/// v7.2.2 — `Database` is `Send`, so the recommended sharing
3745/// pattern for multi-threaded callers is `Arc<Mutex<Database>>`:
3746///
3747/// ```no_run
3748/// use std::sync::{Arc, Mutex};
3749/// use spg_embedded::Database;
3750///
3751/// let db = Database::open_in_memory();
3752/// let shared = Arc::new(Mutex::new(db));
3753/// let shared_for_worker = Arc::clone(&shared);
3754/// std::thread::spawn(move || {
3755///     let mut guard = shared_for_worker.lock().unwrap();
3756///     guard.execute("INSERT INTO t VALUES (1)").unwrap();
3757/// });
3758/// ```
3759///
3760/// Internal `RwLock`-wrapped state — letting many threads
3761/// hold concurrent `&Database` for `SELECT` without contending
3762/// — is parked as STABILITY § "Out of v7.2"; multi-reader
3763/// embedded throughput needs a planner-side change to release
3764/// the engine read lock between scans, which is the v7.x
3765/// "Choice A" line of work already documented in v6.9.1's
3766/// carve-out.
3767#[allow(dead_code)]
3768fn _database_is_send() {
3769    fn assert_send<T: Send>() {}
3770    assert_send::<Database>();
3771}
3772
3773/// v6.10.3 — trait that maps a row's columns onto a user
3774/// struct's fields. v7.3.0 ships the [`spg_row!`] declarative
3775/// macro that generates `impl FromSpgRow for YourStruct` from
3776/// a struct definition (no proc-macro, no syn/quote/
3777/// proc-macro2 deps — the workspace's "0 external deps"
3778/// policy holds).
3779///
3780/// Implementors map a row's columns onto a user struct's
3781/// fields. Errors surface as `EngineError::Unsupported` so the
3782/// caller's error type stays uniform.
3783pub trait FromSpgRow: Sized {
3784    /// Decode one query result row into `Self`. Called once per
3785    /// row by [`Database::query_typed`]. The slice length equals
3786    /// the number of columns in the SELECT projection.
3787    fn from_spg_row(row: &[Value]) -> Result<Self, EngineError>;
3788}
3789
3790/// v7.3.0 — declarative macro that generates `FromSpgRow` impl
3791/// for a user struct. Avoids proc-macro deps
3792/// (syn/quote/proc-macro2) so the workspace's 0-deps policy
3793/// holds; the trade-off vs `#[derive(SpgRow)]` is that the
3794/// macro takes the entire struct definition (fields + types)
3795/// as input rather than annotating an existing struct.
3796///
3797/// ```no_run
3798/// use spg_embedded::{Database, spg_row, FromSpgRow};
3799///
3800/// spg_row! {
3801///     pub struct User {
3802///         pub id: i32,
3803///         pub name: String,
3804///     }
3805/// }
3806///
3807/// let mut db = Database::open_in_memory();
3808/// db.execute("CREATE TABLE users (id INT NOT NULL, name TEXT)").unwrap();
3809/// db.execute("INSERT INTO users VALUES (1, 'alice')").unwrap();
3810/// let users: Vec<User> = db.query_typed("SELECT id, name FROM users").unwrap();
3811/// ```
3812///
3813/// Supported field types: `i16`, `i32`, `i64`, `f32`, `f64`,
3814/// `bool`, `String`, `Vec<f32>` (for `VECTOR(N)` columns),
3815/// `Option<T>` of any of the above.
3816#[macro_export]
3817macro_rules! spg_row {
3818    (
3819        $(#[$meta:meta])*
3820        $vis:vis struct $name:ident {
3821            $(
3822                $(#[$fmeta:meta])*
3823                $fvis:vis $field:ident : $ty:ty,
3824            )*
3825        }
3826    ) => {
3827        $(#[$meta])*
3828        #[derive(Debug, Clone)]
3829        $vis struct $name {
3830            $(
3831                $(#[$fmeta])*
3832                $fvis $field : $ty,
3833            )*
3834        }
3835
3836        impl $crate::FromSpgRow for $name {
3837            fn from_spg_row(row: &[$crate::Value]) -> ::core::result::Result<Self, $crate::EngineError> {
3838                let mut __spg_row_iter = row.iter();
3839                $(
3840                    let $field: $ty = {
3841                        let v = __spg_row_iter
3842                            .next()
3843                            .ok_or_else(|| $crate::EngineError::Unsupported(
3844                                ::std::format!(
3845                                    "spg_row! {}: missing column for field `{}`",
3846                                    ::core::stringify!($name),
3847                                    ::core::stringify!($field)
3848                                )
3849                            ))?;
3850                        <$ty as $crate::FromSpgValue>::from_spg_value(v)
3851                            .map_err(|e| $crate::EngineError::Unsupported(
3852                                ::std::format!(
3853                                    "spg_row! {}: column `{}`: {}",
3854                                    ::core::stringify!($name),
3855                                    ::core::stringify!($field),
3856                                    e
3857                                )
3858                            ))?
3859                    };
3860                )*
3861                Ok(Self { $($field,)* })
3862            }
3863        }
3864    };
3865}
3866
3867/// v7.3.0 — per-column decoder used by `spg_row!`. Surface
3868/// covers every numeric / text / bytes / bool variant in
3869/// `Value`, plus `Option<T>` for nullable columns.
3870pub trait FromSpgValue: Sized {
3871    /// Decode one cell into `Self`. The returned `&'static str`
3872    /// is a short diagnostic for type mismatches (e.g. `"expected
3873    /// integer, got TEXT"`); callers wrap it into their own
3874    /// error type.
3875    fn from_spg_value(v: &Value) -> Result<Self, &'static str>;
3876}
3877
3878macro_rules! impl_from_value_int {
3879    ($($t:ty),* $(,)?) => {
3880        $(
3881            impl FromSpgValue for $t {
3882                fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
3883                    match v {
3884                        Value::SmallInt(n) => <$t>::try_from(*n).map_err(|_| "SmallInt does not fit target int type"),
3885                        Value::Int(n)      => <$t>::try_from(*n).map_err(|_| "Int does not fit target int type"),
3886                        Value::BigInt(n)   => <$t>::try_from(*n).map_err(|_| "BigInt does not fit target int type"),
3887                        Value::Null        => Err("NULL in non-Option int column"),
3888                        _ => Err("non-integer value in int column"),
3889                    }
3890                }
3891            }
3892        )*
3893    };
3894}
3895impl_from_value_int!(i16, i32, i64);
3896
3897impl FromSpgValue for f32 {
3898    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
3899        match v {
3900            Value::Float(f) => Ok(*f as f32),
3901            Value::Null => Err("NULL in non-Option float column"),
3902            _ => Err("non-float value in float column"),
3903        }
3904    }
3905}
3906
3907impl FromSpgValue for f64 {
3908    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
3909        match v {
3910            Value::Float(f) => Ok(*f),
3911            Value::Null => Err("NULL in non-Option float column"),
3912            _ => Err("non-float value in float column"),
3913        }
3914    }
3915}
3916
3917impl FromSpgValue for bool {
3918    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
3919        match v {
3920            Value::Bool(b) => Ok(*b),
3921            Value::Null => Err("NULL in non-Option bool column"),
3922            _ => Err("non-bool value in bool column"),
3923        }
3924    }
3925}
3926
3927impl FromSpgValue for String {
3928    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
3929        match v {
3930            Value::Text(s) => Ok(s.to_string()),
3931            Value::Null => Err("NULL in non-Option text column"),
3932            _ => Err("non-text value in String column"),
3933        }
3934    }
3935}
3936
3937impl FromSpgValue for Vec<f32> {
3938    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
3939        match v {
3940            Value::Vector(xs) => Ok(xs.to_vec()),
3941            Value::Null => Err("NULL in non-Option vector column"),
3942            _ => Err("non-vector value in Vec<f32> column"),
3943        }
3944    }
3945}
3946
3947impl<T: FromSpgValue> FromSpgValue for Option<T> {
3948    fn from_spg_value(v: &Value) -> Result<Self, &'static str> {
3949        match v {
3950            Value::Null => Ok(None),
3951            other => T::from_spg_value(other).map(Some),
3952        }
3953    }
3954}
3955
3956/// Acquire the cross-process exclusion lock at `lock_path` (atomic
3957/// `mkdir`), recording the owner pid inside. If the lock already
3958/// exists, read the recorded pid and probe liveness — a lock left
3959/// behind by a killed process (docker SIGKILL, crash) is reclaimed
3960/// automatically instead of forcing the operator to delete it by
3961/// hand (mailrs embed round-12: a restarted server came up in
3962/// degraded mode because the previous instance's lock survived).
3963/// v7.27 (mailrs round-21 B) — the prober's environment identity:
3964/// `(hostname, boot-or-container id)`. A pid is only meaningful
3965/// inside the PID namespace that recorded it; mailrs's recovery
3966/// window saw "locked by pid 1" from a STOPPED container because
3967/// the prober's pid 1 (its own init) was alive. When the lock's
3968/// identity differs from ours, liveness is UNDECIDABLE and we
3969/// refuse honestly instead of guessing in either direction.
3970fn host_identity() -> (String, String) {
3971    let hostname = std::process::Command::new("hostname")
3972        .output()
3973        .ok()
3974        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
3975        .unwrap_or_default();
3976    // Linux boot id; containers share the host kernel's boot id, so
3977    // hostname (= container id by default) is the namespace
3978    // discriminator and boot id catches host reboots / pid reuse.
3979    let boot_id = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
3980        .map(|s| s.trim().to_string())
3981        .or_else(|_| {
3982            std::process::Command::new("sysctl")
3983                .args(["-n", "kern.bootsessionuuid"])
3984                .output()
3985                .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
3986        })
3987        .unwrap_or_default();
3988    (hostname, boot_id)
3989}
3990
3991/// v7.34 (crash-recovery P0 #2) — process start-time, to tell a reused
3992/// pid apart from a genuinely-held lock. In a container the holder is
3993/// always pid 1; `docker start` reuses the container so the NEW process
3994/// is pid 1 too, on the same host+boot id — a bare `pid_alive(1)` probe
3995/// (`ps -p 1` always succeeds) reads a dead owner's lock as live and the
3996/// engine self-deadlocks on its own catalog. The `(pid, start-time)`
3997/// pair is unique per live process within a boot: a reused pid carries a
3998/// LATER start-time, so a mismatch means the recorded owner is gone.
3999/// Linux reads `/proc/<pid>/stat` field 22 (clock ticks since boot);
4000/// `comm` (field 2) is parenthesised and may contain spaces, so fields
4001/// are taken after the LAST ')'. Other platforms return None and the
4002/// liveness check falls back to pid-alive + the self-pid reclaim. Pure
4003/// std — no libc.
4004#[cfg(target_os = "linux")]
4005fn process_start_time(pid: u32) -> Option<String> {
4006    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
4007    let after = stat.rsplit_once(')').map(|(_, rest)| rest)?;
4008    // After comm: state(1) ppid(2) … starttime is the 20th token.
4009    after.split_whitespace().nth(19).map(str::to_string)
4010}
4011
4012#[cfg(not(target_os = "linux"))]
4013fn process_start_time(_pid: u32) -> Option<String> {
4014    None
4015}
4016
4017/// v7.37.5 (mailrs crash-recovery Ask 1) — in-process registry of
4018/// lock paths currently being opened or held by a live `Database`
4019/// instance in THIS process. Closes the v7.37.10 design gap that
4020/// kept the mailrs lock-hang alive across recurrences:
4021///
4022/// `AsyncDatabase::open_path` runs `Database::open_path` inside
4023/// `tokio::task::spawn_blocking`, which CANNOT be cancelled
4024/// mid-flight. When the awaiting future is dropped (pool
4025/// acquire-timeout, ctrl-c on a slow boot, etc.), the blocking
4026/// task keeps running and STILL HOLDS the lock. A concurrent
4027/// retry then reads the on-disk lock, sees `(pid, start-time)`
4028/// matching its OWN process, and the pid-1 + start-time logic
4029/// declares the lock "owner_alive=true" — refusing to reclaim a
4030/// lock that is, in fact, held by a sibling task in the same
4031/// process. Result: every retry hangs until the in-flight open
4032/// completes (≥ 27 min on the 1.5 MB mailrs WAL before Ask 3).
4033///
4034/// The on-disk identity (pid + start-time + hostname + boot id)
4035/// is sufficient ACROSS processes but ambiguous WITHIN one
4036/// process; this set settles it directly. `acquire_path_lock`
4037/// consults the set first: if the path is present, the on-disk
4038/// lock is held by a live sibling task and we refuse honestly
4039/// without reading the pid file. If absent, a same-pid on-disk
4040/// lock is necessarily a previous-generation orphan (the prior
4041/// holder dropped its `LockRegistryGuard` on Drop, so the set
4042/// no longer contains the path) and the existing pid-1 / stale
4043/// reclaim path handles it.
4044fn active_open_paths() -> &'static std::sync::Mutex<std::collections::HashSet<PathBuf>> {
4045    use std::sync::OnceLock;
4046    static ACTIVE: OnceLock<std::sync::Mutex<std::collections::HashSet<PathBuf>>> = OnceLock::new();
4047    ACTIVE.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
4048}
4049
4050/// RAII guard that registers a `lock_path` in `ACTIVE_OPEN_PATHS`
4051/// on construction and de-registers on Drop. Construction fails
4052/// with `EngineError::Unsupported` when the path is already
4053/// present — that's the v7.37.5 honest refusal for a sibling
4054/// in-flight `Database::open_path` on the same path. Carried by
4055/// `Database` for the live duration of the handle so concurrent
4056/// open attempts see the registration even while the prior open's
4057/// `spawn_blocking` task is still in WAL replay.
4058#[derive(Debug)]
4059pub(crate) struct LockRegistryGuard {
4060    path: PathBuf,
4061}
4062
4063impl LockRegistryGuard {
4064    fn try_acquire(lock_path: &Path) -> Result<Self, EngineError> {
4065        let mut set = active_open_paths()
4066            .lock()
4067            .unwrap_or_else(|e| e.into_inner());
4068        if set.contains(lock_path) {
4069            return Err(EngineError::Unsupported(format!(
4070                "database is locked by an in-flight task in this process: {} \
4071                 (a sibling `Database::open_path` / `AsyncDatabase::open_path` is \
4072                 still holding the lock; wait for it to complete, or shut down the \
4073                 prior caller before retrying)",
4074                lock_path.display()
4075            )));
4076        }
4077        set.insert(lock_path.to_path_buf());
4078        Ok(Self {
4079            path: lock_path.to_path_buf(),
4080        })
4081    }
4082}
4083
4084impl Drop for LockRegistryGuard {
4085    fn drop(&mut self) {
4086        let mut set = active_open_paths()
4087            .lock()
4088            .unwrap_or_else(|e| e.into_inner());
4089        set.remove(&self.path);
4090    }
4091}
4092
4093/// v7.37.5 — diagnostic predicate used by tests + future cross-
4094/// boundary force_unlock plumbing (Ask 2) to decide whether a
4095/// same-process retry should refuse honestly vs. reclaim.
4096#[doc(hidden)]
4097pub fn is_lock_path_active_in_process(lock_path: &Path) -> bool {
4098    active_open_paths()
4099        .lock()
4100        .map(|s| s.contains(lock_path))
4101        .unwrap_or(false)
4102}
4103
4104fn acquire_path_lock(lock_path: &Path) -> Result<(), EngineError> {
4105    // v7.37.5 (Ask 1) — the in-process registry check happens in
4106    // `LockRegistryGuard::try_acquire`, called by `open_path`
4107    // BEFORE this function. By the time we get here, the caller
4108    // already owns the registry slot; the on-disk acquire below
4109    // can race with same-pid siblings only when force_unlock
4110    // cleared the registry mid-flight (the operator's
4111    // single-instance contract), which is correct behaviour.
4112    for attempt in 0..2 {
4113        match std::fs::create_dir(lock_path) {
4114            Ok(()) => {
4115                // Best-effort owner record; liveness probing treats a
4116                // missing pid file as stale (crash between mkdir and
4117                // write is indistinguishable from an ancient lock).
4118                // v7.27 — lines 2+3 record the owner's environment
4119                // identity (hostname, boot id) so a prober in a
4120                // different namespace refuses instead of misreading
4121                // the pid. v7.34 — line 4 records the owner's process
4122                // start-time so a reused pid (container pid-1 restart)
4123                // is distinguishable from a live holder.
4124                let (host, boot) = host_identity();
4125                let start = process_start_time(std::process::id()).unwrap_or_default();
4126                let _ = std::fs::write(
4127                    lock_path.join("pid"),
4128                    format!("{}\n{host}\n{boot}\n{start}\n", std::process::id()),
4129                );
4130                return Ok(());
4131            }
4132            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempt == 0 => {
4133                let record = std::fs::read_to_string(lock_path.join("pid")).unwrap_or_default();
4134                let mut lines = record.lines();
4135                let owner = lines.next().and_then(|s| s.trim().parse::<u32>().ok());
4136                let lock_host = lines.next().unwrap_or("").trim().to_string();
4137                let lock_boot = lines.next().unwrap_or("").trim().to_string();
4138                let lock_start = lines.next().unwrap_or("").trim().to_string();
4139                // Note(v7.37.10 design choice): we do NOT auto-reclaim a
4140                // lock whose (pid, start-time) matches OUR own process.
4141                // Tempting fix for the mailrs 2026-06-19 recurrence —
4142                // "the prior open_path future got cancelled, its lock
4143                // leaked" — but `AsyncDatabase::open_path` runs the
4144                // blocking `Database::open_path` inside
4145                // `tokio::task::spawn_blocking`, which CANNOT be
4146                // cancelled mid-flight. When the awaiting future is
4147                // dropped (pool acquire-timeout), the spawn_blocking
4148                // task keeps running and STILL HOLDS the lock; auto-
4149                // reclaiming would let a concurrent retry steal a live
4150                // task's lock and corrupt WAL replay. The mailrs flow
4151                // is correctly resolved by waiting for the in-flight
4152                // replay to finish — sentori's spg-sqlx pool config
4153                // needs a higher `acquire_timeout` than spg's worst-
4154                // case replay time. Tracking that separately as a
4155                // spg-sqlx pool-default change for v7.38.
4156                // v7.27 — identity check BEFORE the pid probe. A pid
4157                // recorded in another namespace is undecidable both
4158                // ways (a stale lock can look held, a held lock can
4159                // look stale — the unsafe direction). Old-format
4160                // locks (pid only) keep the legacy same-host
4161                // assumption.
4162                // v7.37.10 — skip host_identity when the recorded owner
4163                // is PID 1. PID 1 means containerised; `docker compose
4164                // up -d` recreates the container with a new hostname so
4165                // a strict host-identity match would refuse every
4166                // restart even when the start-time check below would
4167                // correctly declare the old generation stale. The
4168                // start-time check is more accurate for the container
4169                // case anyway — let it decide.
4170                let lock_is_pid1 = owner == Some(1);
4171                if !lock_host.is_empty() && !lock_is_pid1 {
4172                    let (my_host, my_boot) = host_identity();
4173                    let same_env = lock_host == my_host
4174                        && (lock_boot.is_empty() || my_boot.is_empty() || lock_boot == my_boot);
4175                    if !same_env {
4176                        return Err(EngineError::Unsupported(format!(
4177                            "database lock {} was taken in a different host/container \
4178                             (owner: pid {} on {:?}; we are {:?}) — liveness is \
4179                             undecidable from here. If you are sure the owner is gone, \
4180                             call Database::force_unlock() or `spg import --force-unlock`.",
4181                            lock_path.display(),
4182                            owner.unwrap_or(0),
4183                            lock_host,
4184                            my_host
4185                        )));
4186                    }
4187                }
4188                // v7.34 (crash-recovery P0 #2) — pid-reuse-safe liveness.
4189                // A bare `pid_alive` self-deadlocks in a container: the
4190                // dead owner was pid 1, `docker start` reuses the container
4191                // so the prober is pid 1 too, and `ps -p 1` always succeeds.
4192                // The recorded (pid, start-time) pair settles it — the
4193                // owner is alive ONLY if its pid is alive AND its CURRENT
4194                // start-time still matches the recorded one:
4195                //  - container restart: pid 1 alive, but the new pid-1's
4196                //    start-time differs from the dead owner's → stale.
4197                //  - genuine double-open (same live process): start-time
4198                //    matches (it wrote it) → held — correctly refused, so a
4199                //    second writer can't steal a live lock.
4200                // v7.37.10 — for PID-1 owners with no recorded start-time
4201                // (a pre-v7.34 lock from a previous container generation),
4202                // treat as stale: a new container's PID 1 cannot share
4203                // identity with the previous container's PID 1. Gated on
4204                // `process_start_time` having returned `Some(_)` so the
4205                // arm only fires on Linux (where /proc is queryable); on
4206                // macOS, where PID 1 is `launchd` (a real long-running
4207                // system process), the empty-start-time fallback keeps
4208                // the safer pid-alive answer.
4209                let owner_alive = owner.is_some_and(|p| {
4210                    if !pid_alive(p) {
4211                        return false;
4212                    }
4213                    let now = process_start_time(p);
4214                    match (now, lock_start.is_empty()) {
4215                        (Some(t), false) => t == lock_start,
4216                        (Some(_), true) if p == 1 => false,
4217                        _ => true,
4218                    }
4219                });
4220                if owner_alive {
4221                    return Err(EngineError::Unsupported(format!(
4222                        "database is locked by another process (pid {}): {}; \
4223                         stop that process first, or call Database::force_unlock()",
4224                        owner.unwrap_or(0),
4225                        lock_path.display()
4226                    )));
4227                }
4228                // Stale — owner pid dead, reused, or unrecorded. Reclaim.
4229                eprintln!(
4230                    "spg-embedded: reclaiming stale lock {} (owner pid {:?} not a live holder)",
4231                    lock_path.display(),
4232                    owner
4233                );
4234                std::fs::remove_dir_all(lock_path).map_err(io_err)?;
4235                // Loop retries the create_dir; a concurrent reclaimer
4236                // winning the race surfaces as AlreadyExists on
4237                // attempt 1 below.
4238            }
4239            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
4240                return Err(EngineError::Unsupported(format!(
4241                    "database is locked by another process: {}; \
4242                     stop that process first, or call Database::force_unlock()",
4243                    lock_path.display()
4244                )));
4245            }
4246            Err(e) => return Err(io_err(e)),
4247        }
4248    }
4249    unreachable!("acquire_path_lock loop covers both attempts")
4250}
4251
4252/// Probe whether `pid` is a live process. Unix: `ps -p` via the
4253/// system binary (std-only — no libc dependency). `ps -p` exits 0
4254/// for ANY live pid regardless of owner; `kill -0` was rejected
4255/// here because it fails with EPERM on another user's live process,
4256/// which would read as "dead" and reclaim a held lock. Probe
4257/// failure (no `ps` binary, exec error) conservatively reports
4258/// alive so locks are never auto-reclaimed on doubt; non-unix
4259/// targets do the same.
4260#[cfg(unix)]
4261fn pid_alive(pid: u32) -> bool {
4262    // v7.37.10 — `/proc/<pid>` directory existence is the
4263    // most reliable liveness signal on Linux, and crucially
4264    // doesn't depend on `procps` being installed in the
4265    // container image. Minimal images(rust:slim, distroless,
4266    // mailrs-mmalloc's stripped runtime)don't ship `ps`, and
4267    // a failed `Command::spawn` previously fell back to
4268    // "treat as alive" — which inverted the meaning of every
4269    // stale-lock probe in those environments. Probe /proc
4270    // first on Linux; fall back to `ps -p` on other unix
4271    // (macOS / BSD), where procps-equivalent tools ship by
4272    // default.
4273    #[cfg(target_os = "linux")]
4274    {
4275        if std::path::Path::new("/proc").is_dir() {
4276            return std::path::Path::new(&format!("/proc/{pid}")).exists();
4277        }
4278    }
4279    match std::process::Command::new("ps")
4280        .arg("-p")
4281        .arg(pid.to_string())
4282        .stdout(std::process::Stdio::null())
4283        .stderr(std::process::Stdio::null())
4284        .status()
4285    {
4286        Ok(status) => status.success(),
4287        Err(_) => true,
4288    }
4289}
4290
4291#[cfg(not(unix))]
4292fn pid_alive(_pid: u32) -> bool {
4293    true
4294}
4295
4296/// Strip leading whitespace, `--` line comments and NON-conditional
4297/// block comments from a chunk so statement-head checks (COPY
4298/// detection most notably) see the first real token. pg_dump
4299/// prefixes every data block with a `-- Data for Name: …;` banner —
4300/// which itself contains semicolons, so head checks must run on the
4301/// stripped text. MySQL executable conditional comments (`/*!`) are
4302/// content and stay.
4303/// v7.22 — see `split_statements`' `mysql_escapes` tracking. Only
4304/// short chunks are inspected (the signal statements are one-liners;
4305/// COPY data blocks are skipped by the length guard).
4306fn note_dialect_signals(chunk: &str, mysql_escapes: &mut bool) {
4307    if chunk.len() > 4096 {
4308        return;
4309    }
4310    let lower = chunk.to_ascii_lowercase();
4311    if lower.contains("sql_mode") {
4312        *mysql_escapes = true;
4313    } else if lower.contains("standard_conforming_strings") {
4314        *mysql_escapes = lower.contains("off");
4315    }
4316}
4317
4318fn strip_leading_sql_noise(mut s: &str) -> &str {
4319    loop {
4320        let t = s.trim_start();
4321        if let Some(rest) = t.strip_prefix("--") {
4322            s = rest.split_once('\n').map_or("", |(_, r)| r);
4323            continue;
4324        }
4325        if t.starts_with("/*") && !t.starts_with("/*!") {
4326            match t.find("*/") {
4327                Some(e) => {
4328                    s = &t[e + 2..];
4329                    continue;
4330                }
4331                None => return "",
4332            }
4333        }
4334        return t;
4335    }
4336}
4337
4338/// Split a multi-statement SQL script into individual statements on
4339/// top-level `;`, honouring single-quoted strings (with `''`
4340/// escapes), double-quoted identifiers, dollar-quoted bodies
4341/// (`$tag$ … $tag$`), line comments (`--`) and MySQL executable
4342/// conditional comments (`/*!… */` stay statement content; plain
4343/// nested block comments don't). Chunks that contain no statement
4344/// content (whitespace / comments only) are dropped. PG's
4345/// simple-query protocol does this server-side; the embed path owns
4346/// it here.
4347///
4348/// v7.22 (mailrs round-13 gap 1) — psql meta-command lines are
4349/// dropped for client parity: a line whose first non-whitespace
4350/// byte is `\` BETWEEN statements (PG 18's pg_dump wraps scripts in
4351/// `\restrict` / `\unrestrict`) never reaches the parser, the same
4352/// way psql consumes `\`-lines client-side and never sends them. A
4353/// mid-statement backslash stays an ordinary byte — pg_dump only
4354/// emits meta-commands between statements.
4355pub fn split_statements(sql: &str) -> Vec<&str> {
4356    let bytes = sql.as_bytes();
4357    let mut stmts = Vec::new();
4358    let mut start = 0usize;
4359    let mut has_content = false;
4360    // v7.22 (round-13 T3) — stream-tracked string dialect, mirroring
4361    // the engine's session flag: a statement mentioning `sql_mode`
4362    // (mysqldump preamble, often inside `/*!…*/`) switches plain
4363    // strings to backslash-escape scanning;
4364    // `standard_conforming_strings` (pg_dump preamble) switches
4365    // back. Without this the scanner ends a MySQL `'…\'…'` literal
4366    // early and splits inside data.
4367    let mut mysql_escapes = false;
4368    let mut i = 0usize;
4369    while i < bytes.len() {
4370        match bytes[i] {
4371            b'\\' if !has_content => {
4372                // Start-of-statement `\` = psql meta-command line.
4373                // Consume through end-of-line; restart the chunk
4374                // after it so the line never lands in the output.
4375                while i < bytes.len() && bytes[i] != b'\n' {
4376                    i += 1;
4377                }
4378                start = if i < bytes.len() { i + 1 } else { i };
4379            }
4380            b'\'' => {
4381                has_content = true;
4382                // PG escape-string form `E'...'` honours backslash
4383                // escapes (`E'a\';b'` is ONE literal) — detect via
4384                // the immediately-preceding standalone E/e. MySQL
4385                // dialect sessions treat EVERY plain string that way.
4386                let escape_string = mysql_escapes
4387                    || (i >= 1
4388                        && matches!(bytes[i - 1], b'e' | b'E')
4389                        && !(i >= 2
4390                            && (bytes[i - 2].is_ascii_alphanumeric() || bytes[i - 2] == b'_')));
4391                i += 1;
4392                while i < bytes.len() {
4393                    if escape_string && bytes[i] == b'\\' {
4394                        // Skip the escaped byte (covers \' and \\).
4395                        i += 2;
4396                        continue;
4397                    }
4398                    if bytes[i] == b'\'' {
4399                        // `''` is an escaped quote inside the literal.
4400                        if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
4401                            i += 2;
4402                            continue;
4403                        }
4404                        break;
4405                    }
4406                    i += 1;
4407                }
4408            }
4409            b'"' => {
4410                has_content = true;
4411                i += 1;
4412                while i < bytes.len() && bytes[i] != b'"' {
4413                    i += 1;
4414                }
4415            }
4416            b'$' => {
4417                // Possible dollar-quote opener `$tag$` (tag may be
4418                // empty). If the shape doesn't match, it's a plain
4419                // `$` (positional param) — fall through.
4420                let tag_end = bytes[i + 1..]
4421                    .iter()
4422                    .position(|&b| !(b.is_ascii_alphanumeric() || b == b'_'))
4423                    .map(|off| i + 1 + off);
4424                if let Some(te) = tag_end
4425                    && te < bytes.len()
4426                    && bytes[te] == b'$'
4427                {
4428                    has_content = true;
4429                    let tag = &sql[i..=te];
4430                    // Find the closing `$tag$`.
4431                    if let Some(close) = sql[te + 1..].find(tag) {
4432                        i = te + 1 + close + tag.len();
4433                        continue;
4434                    }
4435                    // Unterminated — consume the rest; the parser
4436                    // will report it.
4437                    i = bytes.len();
4438                    continue;
4439                }
4440                has_content = true;
4441            }
4442            b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
4443                while i < bytes.len() && bytes[i] != b'\n' {
4444                    i += 1;
4445                }
4446            }
4447            b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
4448                // v7.22 (round-13 T3) — MySQL conditional comments
4449                // `/*!40101 … */` are EXECUTABLE (mysqldump wraps
4450                // its whole preamble + DISABLE KEYS hints in them);
4451                // they must stay statement content for the engine,
4452                // not be skipped as commentary.
4453                if i + 2 < bytes.len() && bytes[i + 2] == b'!' {
4454                    has_content = true;
4455                }
4456                let mut depth = 1usize;
4457                i += 2;
4458                while i < bytes.len() && depth > 0 {
4459                    if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
4460                        depth += 1;
4461                        i += 2;
4462                    } else if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
4463                        depth -= 1;
4464                        i += 2;
4465                    } else {
4466                        i += 1;
4467                    }
4468                }
4469                continue;
4470            }
4471            b';' => {
4472                if has_content {
4473                    let head = &sql[start..i];
4474                    // v7.22 (round-13 T2) — a `COPY … FROM stdin;`
4475                    // statement owns its following data block
4476                    // through the `\.` terminator line (data lines
4477                    // may contain `;`, so generic splitting would
4478                    // shred them). Swallow head + data into ONE
4479                    // chunk; `execute_script` lowers it to INSERTs.
4480                    // pg_dump prefixes the COPY with a comment
4481                    // banner — strip it before the head check.
4482                    let head_clean = strip_leading_sql_noise(head);
4483                    let is_copy_head = head_clean
4484                        .get(..4)
4485                        .is_some_and(|p| p.eq_ignore_ascii_case("copy"))
4486                        && spg_engine::copy::parse_copy_from_stdin_head(head_clean).is_some();
4487                    if is_copy_head {
4488                        // Scan whole lines after the ';' until the
4489                        // `\.` terminator (or EOF — torn dumps lose
4490                        // their tail, same as psql would error).
4491                        let mut j = i + 1;
4492                        let data_end;
4493                        loop {
4494                            if j >= bytes.len() {
4495                                data_end = bytes.len();
4496                                break;
4497                            }
4498                            let line_end = sql[j..].find('\n').map_or(bytes.len(), |off| j + off);
4499                            if sql[j..line_end].trim_end_matches('\r').trim() == "\\." {
4500                                data_end = j;
4501                                i = line_end; // bottom i += 1 skips \n
4502                                break;
4503                            }
4504                            j = line_end + 1;
4505                        }
4506                        stmts.push(&sql[start..data_end]);
4507                        if data_end == bytes.len() {
4508                            i = bytes.len();
4509                        }
4510                        start = i + 1;
4511                        has_content = false;
4512                        i += 1;
4513                        continue;
4514                    }
4515                    note_dialect_signals(head, &mut mysql_escapes);
4516                    stmts.push(head);
4517                }
4518                start = i + 1;
4519                has_content = false;
4520            }
4521            b => {
4522                if !b.is_ascii_whitespace() {
4523                    has_content = true;
4524                }
4525            }
4526        }
4527        i += 1;
4528    }
4529    if has_content {
4530        stmts.push(&sql[start..]);
4531    }
4532    stmts
4533}
4534
4535#[cfg(test)]
4536mod tests {
4537    use super::*;
4538
4539    #[test]
4540    fn split_statements_basic_and_trailing() {
4541        assert_eq!(
4542            split_statements("CREATE TABLE a (x INT); INSERT INTO a VALUES (1)"),
4543            vec!["CREATE TABLE a (x INT)", " INSERT INTO a VALUES (1)"]
4544        );
4545        // whitespace/comment-only chunks drop
4546        assert!(split_statements("  ;; -- nothing\n;").is_empty());
4547    }
4548
4549    #[test]
4550    fn split_statements_quoting_forms() {
4551        // ';' inside a plain literal, a doubled quote, an E-string
4552        // backslash escape, a quoted identifier, and a dollar-quoted
4553        // body must not split.
4554        let cases = [
4555            "INSERT INTO t VALUES ('a;b')",
4556            "INSERT INTO t VALUES ('it''s; fine')",
4557            r"INSERT INTO t VALUES (E'it\'s; fine')",
4558            "CREATE TABLE \"odd;name\" (x INT)",
4559            "DO $body$ BEGIN PERFORM 1; END $body$",
4560            "DO $$ SELECT 1; $$",
4561        ];
4562        for sql in cases {
4563            assert_eq!(split_statements(sql), vec![sql], "must stay whole: {sql}");
4564        }
4565        // ...and each still splits cleanly from a neighbour.
4566        for sql in cases {
4567            let script = format!("{sql};\nSELECT 2");
4568            assert_eq!(
4569                split_statements(&script),
4570                vec![sql, "\nSELECT 2"],
4571                "must split after: {sql}"
4572            );
4573        }
4574    }
4575
4576    #[test]
4577    fn split_statements_drops_psql_meta_lines() {
4578        // v7.22 round-13 gap 1 — PG 18 pg_dump wraps scripts in
4579        // `\restrict` / `\unrestrict`; psql parity = the lines never
4580        // reach the parser.
4581        let script = "\\restrict TOKEN123\nSELECT 1;\n\\unrestrict TOKEN123\nSELECT 2;\n\\.\n";
4582        assert_eq!(split_statements(script), vec!["SELECT 1", "SELECT 2"]);
4583        // Mid-statement backslash is NOT a meta-command.
4584        let s2 = r"SELECT E'a\\b'";
4585        assert_eq!(split_statements(s2), vec![s2]);
4586    }
4587
4588    #[test]
4589    fn split_statements_comments_hide_semicolons() {
4590        let script = "-- c1 ; still comment\nSELECT 1; /* a ; b /* nested ; */ */ SELECT 2";
4591        let got = split_statements(script);
4592        assert_eq!(got.len(), 2);
4593        assert!(got[0].contains("SELECT 1"));
4594        assert!(got[1].contains("SELECT 2"));
4595    }
4596
4597    #[test]
4598    fn in_memory_create_insert_select() {
4599        let mut db = Database::open_in_memory();
4600        db.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
4601            .unwrap();
4602        db.execute("INSERT INTO t VALUES (1, 'alice')").unwrap();
4603        db.execute("INSERT INTO t VALUES (2, 'bob')").unwrap();
4604        let rows = db.query("SELECT id FROM t WHERE id = 1").unwrap();
4605        assert_eq!(rows.len(), 1);
4606        match &rows[0][0] {
4607            Value::Int(1) => {}
4608            other => panic!("expected Int(1), got {other:?}"),
4609        }
4610    }
4611
4612    #[test]
4613    fn query_on_non_select_errors() {
4614        let mut db = Database::open_in_memory();
4615        db.execute("CREATE TABLE t (id INT)").unwrap();
4616        let r = db.query("INSERT INTO t VALUES (1)");
4617        assert!(r.is_err(), "query() on INSERT must error");
4618    }
4619
4620    #[test]
4621    fn snapshot_roundtrip() {
4622        let mut db = Database::open_in_memory();
4623        db.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
4624        db.execute("INSERT INTO t VALUES (42)").unwrap();
4625        let bytes = db.snapshot();
4626        let mut restored = Database::restore(&bytes).unwrap();
4627        let rows = restored.query("SELECT id FROM t WHERE id = 42").unwrap();
4628        assert_eq!(rows.len(), 1);
4629        match &rows[0][0] {
4630            Value::Int(42) => {}
4631            other => panic!("expected Int(42), got {other:?}"),
4632        }
4633    }
4634
4635    #[test]
4636    fn from_spg_row_trait_shape() {
4637        struct User {
4638            _id: i32,
4639        }
4640        impl FromSpgRow for User {
4641            fn from_spg_row(row: &[Value]) -> Result<Self, EngineError> {
4642                match row.first() {
4643                    Some(Value::Int(n)) => Ok(Self { _id: *n }),
4644                    _ => Err(EngineError::Unsupported("bad id".into())),
4645                }
4646            }
4647        }
4648        let row = vec![Value::Int(7)];
4649        let _u = User::from_spg_row(&row).unwrap();
4650    }
4651
4652    // ─────────────────────────────────────────────────────────────
4653    // v7.37.5 — mailrs crash-recovery lock-hang regression tests.
4654    // Three asks; each closed atomically:
4655    //   Ask 1 — in-process registry refuses sibling sl-blocking
4656    //   Ask 2 — force_unlock clears the in-process registry too
4657    //   Ask 3 — apply_redo batches DELETE/INSERT/UPDATE so the
4658    //           index rebuild happens once per replay, not once
4659    //           per WAL record
4660    // ─────────────────────────────────────────────────────────────
4661
4662    fn tmpdir() -> std::path::PathBuf {
4663        let base = std::env::temp_dir().join(format!(
4664            "spg-v7375-lockhang-{}-{}",
4665            std::process::id(),
4666            std::time::SystemTime::now()
4667                .duration_since(std::time::UNIX_EPOCH)
4668                .unwrap()
4669                .as_nanos()
4670        ));
4671        std::fs::create_dir_all(&base).unwrap();
4672        base
4673    }
4674
4675    #[test]
4676    fn ask1_in_process_registry_refuses_sibling_open() {
4677        // Two `Database::open_path` calls in the same process MUST
4678        // NOT both succeed (the second would race the first's WAL
4679        // replay). v7.37.10 leaned on on-disk pid + start-time
4680        // matching; v7.37.5 settles it directly via
4681        // `ACTIVE_OPEN_PATHS`.
4682        let dir = tmpdir();
4683        let db_path = dir.join("t.spg");
4684        let first = Database::open_path(&db_path).expect("first open succeeds");
4685        // Confirm the registry registered this path.
4686        let lock_path = {
4687            let mut p = db_path.clone();
4688            let mut s = p.file_name().unwrap().to_os_string();
4689            s.push(".lock");
4690            p.set_file_name(s);
4691            p
4692        };
4693        assert!(
4694            is_lock_path_active_in_process(&lock_path),
4695            "lock_path must be registered while Database is live"
4696        );
4697        // Sibling open MUST refuse honestly (not hang).
4698        let second = Database::open_path(&db_path);
4699        assert!(
4700            matches!(second, Err(EngineError::Unsupported(_))),
4701            "sibling open_path on same path must refuse, got {second:?}"
4702        );
4703        drop(first);
4704        // Once dropped, the registry releases and a fresh open
4705        // succeeds.
4706        assert!(
4707            !is_lock_path_active_in_process(&lock_path),
4708            "lock_path must be de-registered after Database is dropped"
4709        );
4710        let third = Database::open_path(&db_path);
4711        assert!(
4712            third.is_ok(),
4713            "post-drop open_path on same path must succeed, got {third:?}"
4714        );
4715        let _ = std::fs::remove_dir_all(&dir);
4716    }
4717
4718    #[test]
4719    fn ask2_force_unlock_clears_in_process_registry() {
4720        // `force_unlock` is the operator's "no one owns this catalog"
4721        // assertion. Post-Ask-1 the in-process registry would refuse
4722        // a sibling open even after force_unlock — Ask 2 wires
4723        // force_unlock to ALSO clear the registry so retries see a
4724        // consistent "free" state.
4725        let dir = tmpdir();
4726        let db_path = dir.join("u.spg");
4727        // Open a database to populate the registry, then keep the
4728        // handle so the registry entry survives.
4729        let _first = Database::open_path(&db_path).expect("first open succeeds");
4730        let lock_path = {
4731            let mut p = db_path.clone();
4732            let mut s = p.file_name().unwrap().to_os_string();
4733            s.push(".lock");
4734            p.set_file_name(s);
4735            p
4736        };
4737        assert!(is_lock_path_active_in_process(&lock_path));
4738        // force_unlock — operator declares the catalog free.
4739        Database::force_unlock(&db_path).expect("force_unlock succeeds");
4740        // Registry MUST be cleared (Ask 2 contract).
4741        assert!(
4742            !is_lock_path_active_in_process(&lock_path),
4743            "force_unlock must clear the in-process registry entry"
4744        );
4745        // Disk lock is also gone.
4746        assert!(
4747            !lock_path.exists(),
4748            "force_unlock must remove the on-disk lock dir"
4749        );
4750        let _ = std::fs::remove_dir_all(&dir);
4751    }
4752
4753    #[test]
4754    fn ask3_apply_redo_differential_vs_per_record_path() {
4755        // v7.37.5 — differential test: batched `apply_redo` MUST
4756        // produce the same final catalog state (rows + indices)
4757        // as the legacy per-record path that called the public
4758        // `Table::insert`, `update_row`, `delete_rows` in order.
4759        // Built on a smaller table so the per-record path is
4760        // tractable. Mixes Insert/Update/Delete to exercise the
4761        // composition logic.
4762        use spg_storage::{Catalog, ColumnSchema, Row, RowChange, TableSchema};
4763        use spg_storage::{DataType, Value};
4764
4765        fn build_seed_catalog() -> Catalog {
4766            let columns = vec![
4767                ColumnSchema::new("a", DataType::Int, false),
4768                ColumnSchema::new("b", DataType::Int, false),
4769                ColumnSchema::new("c", DataType::Int, false),
4770            ];
4771            let mut cat = Catalog::new();
4772            cat.create_table(TableSchema::new("t", columns)).unwrap();
4773            // 3 BTree indices on a, b, c.
4774            cat.get_mut("t")
4775                .unwrap()
4776                .add_index("idx_a".into(), "a")
4777                .unwrap();
4778            cat.get_mut("t")
4779                .unwrap()
4780                .add_index("idx_b".into(), "b")
4781                .unwrap();
4782            cat.get_mut("t")
4783                .unwrap()
4784                .add_index("idx_c".into(), "c")
4785                .unwrap();
4786            for r in 0..100 {
4787                cat.get_mut("t")
4788                    .unwrap()
4789                    .insert(Row::new(vec![
4790                        Value::Int(r),
4791                        Value::Int(r * 2),
4792                        Value::Int(r * 3),
4793                    ]))
4794                    .unwrap();
4795            }
4796            cat
4797        }
4798
4799        let changes: Vec<RowChange> = vec![
4800            RowChange::Delete {
4801                table: "t".to_string(),
4802                positions: vec![5, 7, 9],
4803            },
4804            RowChange::Insert {
4805                table: "t".to_string(),
4806                row: Row::new(vec![Value::Int(999), Value::Int(1998), Value::Int(2997)]),
4807            },
4808            RowChange::Update {
4809                table: "t".to_string(),
4810                pos: 3,
4811                new_row: vec![Value::Int(42), Value::Int(84), Value::Int(126)],
4812            },
4813            RowChange::Delete {
4814                table: "t".to_string(),
4815                positions: vec![0, 1],
4816            },
4817        ];
4818
4819        // Path A: the new batched `apply_redo`.
4820        let mut cat_batched = build_seed_catalog();
4821        cat_batched.apply_redo(&changes).unwrap();
4822
4823        // Path B: the legacy per-record path via the public
4824        // `Table` mutators. Position semantics for `Delete` /
4825        // `Update` are identical to `apply_redo`'s composition
4826        // (positions reference the post-prior-change layout).
4827        let mut cat_legacy = build_seed_catalog();
4828        for change in &changes {
4829            match change {
4830                RowChange::Insert { table, row } => {
4831                    cat_legacy
4832                        .get_mut(table)
4833                        .unwrap()
4834                        .insert(row.clone())
4835                        .unwrap();
4836                }
4837                RowChange::Update {
4838                    table,
4839                    pos,
4840                    new_row,
4841                } => {
4842                    cat_legacy
4843                        .get_mut(table)
4844                        .unwrap()
4845                        .update_row(*pos, new_row.clone())
4846                        .unwrap();
4847                }
4848                RowChange::Delete { table, positions } => {
4849                    cat_legacy.get_mut(table).unwrap().delete_rows(positions);
4850                }
4851            }
4852        }
4853
4854        let a = cat_batched.get("t").unwrap();
4855        let b = cat_legacy.get("t").unwrap();
4856        assert_eq!(
4857            a.rows().len(),
4858            b.rows().len(),
4859            "row counts differ after replay"
4860        );
4861        for (i, (ar, br)) in a.rows().iter().zip(b.rows().iter()).enumerate() {
4862            assert_eq!(
4863                ar.values, br.values,
4864                "row {i} differs: batched={:?} legacy={:?}",
4865                ar.values, br.values
4866            );
4867        }
4868    }
4869
4870    #[test]
4871    fn ask3_apply_redo_batches_index_rebuilds() {
4872        // Synthetic reproducer for the 27-min mailrs WAL replay
4873        // hang. Build a 100k-row table with 13 BTree indices, then
4874        // apply 5000 `RowChange::Delete` records via the public
4875        // `Catalog::apply_redo` entry point. Pre-v7.37.5 each
4876        // record triggered a full `rebuild_indices` — minutes of
4877        // CPU. Post-v7.37.5 there's exactly one rebuild at the
4878        // end.
4879        //
4880        // The assertion is a wall-clock budget: even on a slow
4881        // CI box this must complete in well under 10 seconds.
4882        use spg_storage::{Catalog, ColumnSchema, Row, RowChange, TableSchema};
4883        use spg_storage::{DataType, Value};
4884
4885        const N_ROWS: usize = 100_000;
4886        const N_INDICES: usize = 13;
4887        const N_DELETE_RECORDS: usize = 5_000;
4888        const ROWS_PER_RECORD: usize = 1; // mirrors mailrs WAL shape
4889
4890        // Build a catalog with one table, N_INDICES BTree indices
4891        // over int columns.
4892        let columns: Vec<ColumnSchema> = (0..N_INDICES)
4893            .map(|i| ColumnSchema::new(format!("c{i}"), DataType::Int, false))
4894            .collect();
4895        let schema = TableSchema::new("t", columns);
4896        let mut catalog = Catalog::new();
4897        catalog.create_table(schema).unwrap();
4898        for i in 0..N_INDICES {
4899            catalog
4900                .get_mut("t")
4901                .unwrap()
4902                .add_index(format!("idx_c{i}"), &format!("c{i}"))
4903                .unwrap();
4904        }
4905        for r in 0..N_ROWS {
4906            let row = Row::new(
4907                (0..N_INDICES)
4908                    .map(|c| Value::Int((r as i32) * 31 + (c as i32)))
4909                    .collect(),
4910            );
4911            catalog.get_mut("t").unwrap().insert(row).unwrap();
4912        }
4913        // Build the 5000 Delete records. Each record references
4914        // positions valid at the time it would have been written;
4915        // since each removes ROWS_PER_RECORD row (at position 0
4916        // post-prior-deletes), the position stays 0 throughout —
4917        // mirrors a sentinel/oldest-first sweep.
4918        let changes: Vec<RowChange> = (0..N_DELETE_RECORDS)
4919            .map(|_| RowChange::Delete {
4920                table: "t".to_string(),
4921                positions: (0..ROWS_PER_RECORD).collect(),
4922            })
4923            .collect();
4924
4925        let start = std::time::Instant::now();
4926        catalog.apply_redo(&changes).unwrap();
4927        let elapsed = start.elapsed();
4928        let remaining = catalog.get("t").unwrap().rows().len();
4929        assert_eq!(
4930            remaining,
4931            N_ROWS - N_DELETE_RECORDS * ROWS_PER_RECORD,
4932            "expected {} rows left after {} deletes",
4933            N_ROWS - N_DELETE_RECORDS * ROWS_PER_RECORD,
4934            N_DELETE_RECORDS * ROWS_PER_RECORD
4935        );
4936        // 10 s budget — pre-v7.37.5 was 27 minutes on prod-shape;
4937        // post-fix is ~300 ms locally. A 10 s ceiling leaves
4938        // generous headroom for slow CI.
4939        assert!(
4940            elapsed < std::time::Duration::from_secs(10),
4941            "apply_redo of {N_DELETE_RECORDS} DELETE records on {N_ROWS}-row × {N_INDICES}-index table \
4942             took {elapsed:?} — Ask 3 batching regression"
4943        );
4944        eprintln!(
4945            "ask3_apply_redo_batches_index_rebuilds: {N_DELETE_RECORDS} DELETE records \
4946             on {N_ROWS}-row × {N_INDICES}-index table replayed in {elapsed:?}"
4947        );
4948    }
4949}