Skip to main content

spg_embedded/
lib.rs

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