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