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