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