makepad/libs/sqlite_query/tests/crash.rs
Admin ed5de46749 sqlite_query: derived tables get their real names, their predicates, and all their arms
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- store: the ceremony dies — batch publish, one transaction, and the engine stops re-reading its own log
- sqlite: derived tables get their real names, their predicates, and all their arms
2026-08-26 08:49:45 +02:00

394 lines
14 KiB
Rust

//! Crash injection: kill the process at every durable write step of a commit,
//! reopen the database, and require that it is intact and shows either the old
//! state or the new one — never a mixture, never a torn page.
//!
//! The child process is this same test binary re-executed with
//! `MAKEPAD_SQLITE_CRASH_CHILD` set; the pager's crash hook aborts it (no
//! unwinding, no flushing) after the requested number of write steps.
mod common;
use common::*;
use makepad_sqlite::{Connection, Database, Value};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
const ROWS_BEFORE: i64 = 60;
const ROWS_ADDED: i64 = 40;
fn setup(path: &Path) {
setup_mode(path, false)
}
fn setup_mode(path: &Path, wal: bool) {
let mut db = Connection::open(path, Duration::from_secs(5)).expect("open");
if wal {
db.execute("PRAGMA journal_mode=WAL", &[]).expect("wal");
}
db.execute(
"CREATE TABLE t(id INTEGER PRIMARY KEY, k TEXT, v BLOB)",
&[],
)
.unwrap();
db.execute("CREATE INDEX t_by_k ON t(k)", &[]).unwrap();
db.execute("BEGIN IMMEDIATE", &[]).unwrap();
for i in 1..=ROWS_BEFORE {
db.execute(
"INSERT INTO t(id, k, v) VALUES (?1, ?2, ?3)",
&[
Value::Integer(i),
Value::text(format!("k{i:04}")),
Value::Blob(vec![1u8; 120]),
],
)
.unwrap();
}
db.execute("COMMIT", &[]).unwrap();
}
/// The transaction the child runs; also used with no crash to count the steps.
fn mutate(path: &Path) {
let mut db = Connection::open(path, Duration::from_secs(5)).expect("open");
db.execute("BEGIN IMMEDIATE", &[]).unwrap();
for i in ROWS_BEFORE + 1..=ROWS_BEFORE + ROWS_ADDED {
db.execute(
"INSERT INTO t(id, k, v) VALUES (?1, ?2, ?3)",
&[
Value::Integer(i),
Value::text(format!("k{i:04}")),
Value::Blob(vec![2u8; 900]),
],
)
.unwrap();
}
db.execute("DELETE FROM t WHERE id <= 20", &[]).unwrap();
db.execute("UPDATE t SET k = 'updated' WHERE id BETWEEN 30 AND 40", &[])
.unwrap();
db.execute("COMMIT", &[]).unwrap();
}
/// Child mode: run the transaction with the crash countdown armed.
#[test]
fn crash_child() {
let Ok(path) = std::env::var("MAKEPAD_SQLITE_CRASH_CHILD") else {
return; // not the child: nothing to do
};
let at: u64 = std::env::var("MAKEPAD_SQLITE_CRASH_AT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
makepad_sqlite::pager::set_crash_after(at);
mutate(Path::new(&path));
// Reaching this point means the crash point was past the end of the commit.
println!("STEPS {}", makepad_sqlite::pager::write_steps());
}
fn run_child(db: &Path, crash_at: u64) -> (bool, String) {
let exe = std::env::current_exe().expect("test binary path");
let out = Command::new(exe)
.args(["--exact", "crash_child", "--nocapture"])
.env("MAKEPAD_SQLITE_CRASH_CHILD", db)
.env("MAKEPAD_SQLITE_CRASH_AT", crash_at.to_string())
.output()
.expect("spawn child");
(
out.status.success(),
String::from_utf8_lossy(&out.stdout).into_owned(),
)
}
fn state(path: &Path) -> (i64, i64, i64) {
let mut db = Database::open(path).expect("reopen");
let rows = db
.query("SELECT COUNT(*) FROM t", &[])
.expect("count")
.rows[0][0]
.as_integer()
.unwrap_or(-1);
let updated = db
.query("SELECT COUNT(*) FROM t WHERE k = 'updated'", &[])
.expect("count updated")
.rows[0][0]
.as_integer()
.unwrap_or(-1);
let max = db
.query("SELECT COALESCE(MAX(id), 0) FROM t", &[])
.expect("max")
.rows[0][0]
.as_integer()
.unwrap_or(-1);
(rows, updated, max)
}
fn recover_and_check(path: &Path, scratch: &Scratch, tag: &str) -> (i64, i64, i64) {
// Opening read-write is what performs hot-journal recovery.
{
let _conn = Connection::open(path, Duration::from_secs(5)).expect("recover");
}
let mut db = Database::open(path).expect("reopen");
let (pager, schema) = db.parts();
let report = makepad_sqlite::integrity::check(pager, schema, true).expect("integrity");
if !report.ok() {
let keep = scratch.path(&format!("broken-{tag}.db"));
let _ = std::fs::copy(path, &keep);
panic!(
"after a crash at {tag} the database is damaged:\n{}",
report.problems.join("\n")
);
}
drop(db);
if have_sqlite3() {
let out = sqlite3(path, "PRAGMA integrity_check;\n");
assert_eq!(out.trim(), "ok", "sqlite3 disagrees after a crash at {tag}");
}
state(path)
}
#[test]
fn every_crash_point_leaves_a_consistent_database() {
if std::env::var("MAKEPAD_SQLITE_CRASH_CHILD").is_ok() {
return; // this process is a child
}
let scratch = Scratch::new("crash");
let pristine = scratch.path("pristine.db");
setup(&pristine);
let before = state(&pristine);
assert_eq!(before.0, ROWS_BEFORE);
// How many write steps a full commit takes.
let work = scratch.path("count.db");
std::fs::copy(&pristine, &work).unwrap();
let (ok, out) = run_child(&work, 0);
assert!(ok, "uncrashed child failed: {out}");
let steps: u64 = out
.lines()
.find_map(|l| l.strip_prefix("STEPS "))
.and_then(|v| v.trim().parse().ok())
.expect("child reported no step count");
assert!(steps > 5, "expected a multi-step commit, got {steps}");
eprintln!("crash harness: {steps} durable write steps per commit");
let after = state(&work);
assert_eq!(after.0, ROWS_BEFORE + ROWS_ADDED - 20);
assert_eq!(after.1, 11);
// Crash at every single step and check what survives.
let mut crashed = 0;
let mut committed = 0;
for at in 1..=steps {
let path = scratch.path(&format!("crash-{at}.db"));
std::fs::copy(&pristine, &path).unwrap();
let (ok, _) = run_child(&path, at);
if !ok {
crashed += 1;
}
let recovered = recover_and_check(&path, &scratch, &at.to_string());
// The only two acceptable outcomes: the old state or the new one.
assert!(
recovered == before || recovered == after,
"crash at step {at} left a mixture: {recovered:?} (before {before:?}, after {after:?})"
);
if recovered == after {
committed += 1;
}
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("db-journal"));
}
assert!(crashed > 0, "no crash point actually aborted the child");
assert!(
committed > 0,
"no crash point landed after the commit; the harness is not covering the end"
);
}
#[test]
fn every_crash_point_in_wal_mode_leaves_a_consistent_database() {
if std::env::var("MAKEPAD_SQLITE_CRASH_CHILD").is_ok() {
return;
}
let scratch = Scratch::new("crash-wal");
let pristine = scratch.path("pristine.db");
setup_mode(&pristine, true);
let pristine_wal = scratch.path("pristine.db-wal");
let before = state(&pristine);
assert_eq!(before.0, ROWS_BEFORE);
let copy_pair = |from: &Path, to: &Path| {
std::fs::copy(from, to).expect("copy db");
let from_wal = PathBuf::from(format!("{}-wal", from.display()));
let to_wal = PathBuf::from(format!("{}-wal", to.display()));
if from_wal.exists() {
std::fs::copy(&from_wal, &to_wal).expect("copy wal");
} else {
let _ = std::fs::remove_file(&to_wal);
}
};
assert!(pristine_wal.exists(), "the fixture is not in WAL mode");
let work = scratch.path("count.db");
copy_pair(&pristine, &work);
let (ok, out) = run_child(&work, 0);
assert!(ok, "uncrashed child failed: {out}");
let steps: u64 = out
.lines()
.find_map(|l| l.strip_prefix("STEPS "))
.and_then(|v| v.trim().parse().ok())
.expect("child reported no step count");
eprintln!("wal crash harness: {steps} durable write steps per commit");
let after = state(&work);
assert_eq!(after.0, ROWS_BEFORE + ROWS_ADDED - 20);
let mut committed = 0;
for at in 1..=steps {
let path = scratch.path(&format!("wal-crash-{at}.db"));
copy_pair(&pristine, &path);
let (_ok, _) = run_child(&path, at);
let recovered = recover_and_check(&path, &scratch, &format!("wal-{at}"));
assert!(
recovered == before || recovered == after,
"crash at WAL step {at} left a mixture: {recovered:?} (before {before:?}, after {after:?})"
);
if recovered == after {
committed += 1;
}
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(PathBuf::from(format!("{}-wal", path.display())));
}
assert!(committed > 0, "no crash point landed after the commit");
}
/// Child mode for the checkpoint sweep: fold the log with the countdown armed.
#[test]
fn crash_child_checkpoint() {
let Ok(path) = std::env::var("MAKEPAD_SQLITE_CRASH_CHILD_CKPT") else {
return; // not the child: nothing to do
};
let at: u64 = std::env::var("MAKEPAD_SQLITE_CRASH_AT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let mut db = Connection::open(Path::new(&path), Duration::from_secs(5)).expect("open");
makepad_sqlite::pager::set_crash_after(at);
db.execute("PRAGMA wal_checkpoint", &[]).expect("checkpoint");
println!("STEPS {}", makepad_sqlite::pager::write_steps());
}
fn run_checkpoint_child(db: &Path, crash_at: u64) -> (bool, String) {
let exe = std::env::current_exe().expect("test binary path");
let out = Command::new(exe)
.args(["--exact", "crash_child_checkpoint", "--nocapture"])
.env("MAKEPAD_SQLITE_CRASH_CHILD_CKPT", db)
.env("MAKEPAD_SQLITE_CRASH_AT", crash_at.to_string())
.output()
.expect("spawn child");
(
out.status.success(),
String::from_utf8_lossy(&out.stdout).into_owned(),
)
}
/// CRASH SAFETY OF THE CHECKPOINT ITSELF: a writer killed at any durable
/// step of folding the log into the database leaves either the old WAL over
/// a (possibly torn) database — which recovery replays — or the folded
/// state; the logical content is identical at every point, and it is NEVER a
/// mixture. This is the property that lets a connection own its log for
/// life: nothing about crash recovery depends on idle-time lock courtesy.
/// (Every sweep iteration also re-acquires WAL ownership after an aborted
/// owner, which is the no-stale-lock property: POSIX locks die with the
/// process.)
#[test]
fn every_crash_point_in_a_wal_checkpoint_leaves_a_consistent_database() {
if std::env::var("MAKEPAD_SQLITE_CRASH_CHILD").is_ok()
|| std::env::var("MAKEPAD_SQLITE_CRASH_CHILD_CKPT").is_ok()
{
return;
}
let scratch = Scratch::new("crash-ckpt");
let pristine = scratch.path("pristine.db");
setup_mode(&pristine, true);
let pristine_wal = scratch.path("pristine.db-wal");
assert!(pristine_wal.exists(), "the fixture is not in WAL mode");
let before = state(&pristine);
assert_eq!(before.0, ROWS_BEFORE);
let copy_pair = |from: &Path, to: &Path| {
std::fs::copy(from, to).expect("copy db");
let from_wal = PathBuf::from(format!("{}-wal", from.display()));
let to_wal = PathBuf::from(format!("{}-wal", to.display()));
if from_wal.exists() {
std::fs::copy(&from_wal, &to_wal).expect("copy wal");
} else {
let _ = std::fs::remove_file(&to_wal);
}
};
let work = scratch.path("count.db");
copy_pair(&pristine, &work);
let (ok, out) = run_checkpoint_child(&work, 0);
assert!(ok, "uncrashed checkpoint child failed: {out}");
let steps: u64 = out
.lines()
.find_map(|l| l.strip_prefix("STEPS "))
.and_then(|v| v.trim().parse().ok())
.expect("child reported no step count");
assert!(steps > 3, "expected a multi-step checkpoint, got {steps}");
eprintln!("checkpoint crash harness: {steps} durable write steps");
let mut crashed = 0;
for at in 1..=steps {
let path = scratch.path(&format!("ckpt-crash-{at}.db"));
copy_pair(&pristine, &path);
let (ok, _) = run_checkpoint_child(&path, at);
if !ok {
crashed += 1;
}
let recovered = recover_and_check(&path, &scratch, &format!("ckpt-{at}"));
// A checkpoint changes no logical content: every crash point must
// recover to exactly the pre-checkpoint state.
assert_eq!(
recovered, before,
"crash at checkpoint step {at} changed the database's content"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(PathBuf::from(format!("{}-wal", path.display())));
}
assert!(crashed > 0, "no checkpoint crash point actually aborted the child");
}
#[test]
fn a_hot_journal_is_replayed_by_sqlite_too() {
if std::env::var("MAKEPAD_SQLITE_CRASH_CHILD").is_ok() || !have_sqlite3() {
return;
}
let scratch = Scratch::new("crash-cli");
let pristine = scratch.path("p.db");
setup(&pristine);
// Find a crash point in the middle of writing the database file.
let work = scratch.path("count.db");
std::fs::copy(&pristine, &work).unwrap();
let (_, out) = run_child(&work, 0);
let steps: u64 = out
.lines()
.find_map(|l| l.strip_prefix("STEPS "))
.and_then(|v| v.trim().parse().ok())
.unwrap_or(10);
let mid = (steps / 2).max(2);
let path: PathBuf = scratch.path("hot.db");
std::fs::copy(&pristine, &path).unwrap();
let (ok, _) = run_child(&path, mid);
assert!(!ok, "the child should have crashed at step {mid}");
let journal = PathBuf::from(format!("{}-journal", path.display()));
if journal.exists() {
// SQLite must roll the journal back by itself and end up consistent.
let out = sqlite3(&path, "PRAGMA integrity_check;\nSELECT COUNT(*) FROM t;\n");
let mut lines = out.lines();
assert_eq!(lines.next().unwrap_or("").trim(), "ok");
let count: i64 = lines.next().unwrap_or("0").trim().parse().unwrap_or(-1);
assert!(
count == ROWS_BEFORE || count == ROWS_BEFORE + ROWS_ADDED - 20,
"sqlite recovered a mixture: {count} rows"
);
}
}