graphitesql 0.1.6

A pure, safe, no_std Rust re-implementation of SQLite, compatible with the SQLite 3 file format.
Documentation
//! `PRAGMA busy_timeout` makes a cross-process reader **wait** for a foreign
//! writer's lock instead of failing immediately with `Busy`. graphite used to treat
//! `busy_timeout` as purely advisory (never blocking); it now retries a blocked
//! lock acquisition, sleeping with SQLite's escalating delay schedule, until the
//! timeout elapses — exactly like SQLite's default busy handler.
//!
//! The test spawns a **child process** (self-exec) that opens the same database
//! file, takes a write lock (`BEGIN IMMEDIATE`), holds it for a fixed window, then
//! commits. The parent — after the child has the lock — runs a `SELECT`:
//!   * with a generous `busy_timeout`, the read blocks until the child commits and
//!     then succeeds (elapsed ≈ the hold window, not ~0);
//!   * with `busy_timeout = 0`, the read fails fast with `Busy` (the historical
//!     no-wait behaviour), proving the wait is driven by the pragma.

#![cfg(feature = "std")]

use graphitesql::{Connection, Error, Value};
use std::time::{Duration, Instant};

const HOLD_MS: u64 = 700;

/// The child: hold a write lock on the db for `HOLD_MS`, then commit.
fn child_hold_writer() {
    let db = std::env::var("GSQL_BT_DB").expect("GSQL_BT_DB");
    let mut c = Connection::open(&db).expect("child open");
    c.execute("BEGIN IMMEDIATE").expect("child begin immediate");
    c.execute("UPDATE t SET v = v + 1 WHERE id = 1")
        .expect("child write");
    // Signal readiness by printing, so the parent knows the lock is held.
    println!("locked");
    use std::io::Write;
    let _ = std::io::stdout().flush();
    std::thread::sleep(Duration::from_millis(HOLD_MS));
    c.execute("COMMIT").expect("child commit");
}

fn spawn_child(db: &str) -> std::process::Child {
    let exe = std::env::current_exe().expect("current_exe");
    std::process::Command::new(exe)
        .args([
            "--exact",
            "busy_timeout_child",
            "--ignored",
            "--nocapture",
            "--test-threads=1",
        ])
        .env("GSQL_BT_CHILD", "1")
        .env("GSQL_BT_DB", db)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .spawn()
        .expect("spawn child")
}

/// Block until the child prints its "locked" line (its write lock is held).
fn wait_locked(child: &mut std::process::Child) -> bool {
    use std::io::{BufRead, BufReader};
    let Some(out) = child.stdout.take() else {
        return false;
    };
    let mut r = BufReader::new(out);
    let mut line = String::new();
    // The child prints "locked" almost immediately; give it a generous ceiling.
    let deadline = Instant::now() + Duration::from_secs(10);
    while Instant::now() < deadline {
        line.clear();
        match r.read_line(&mut line) {
            Ok(0) => return false,
            // The child prints "locked" via `println!`; under libtest's `--nocapture`
            // it lands on the same line as the runner's `test … ` prefix, so match a
            // substring rather than the line start.
            Ok(_) if line.contains("locked") => return true,
            Ok(_) => continue,
            Err(_) => return false,
        }
    }
    false
}

// This entry runs only when re-executed as the child (env flag set); a normal
// `--ignored` run is a no-op so it never hangs the suite.
#[test]
#[ignore = "spawned as the lock-holding child; no-op otherwise"]
fn busy_timeout_child() {
    if std::env::var("GSQL_BT_CHILD").is_ok() {
        child_hold_writer();
    }
}

#[test]
fn busy_timeout_makes_a_cross_process_reader_wait() {
    let dir = std::env::temp_dir();
    let db = dir
        .join(format!("gsql-bt-{}.db", std::process::id()))
        .to_string_lossy()
        .into_owned();
    let _ = std::fs::remove_file(&db);

    // Seed the table (rollback-journal / delete mode: a writer takes a whole-file
    // exclusive lock, so a foreign reader genuinely contends).
    {
        let mut c = Connection::create(&db).expect("create");
        c.execute("CREATE TABLE t(id INTEGER PRIMARY KEY, v INTEGER)")
            .unwrap();
        c.execute("INSERT INTO t VALUES(1, 100)").unwrap();
    }

    // The child takes and holds the write lock.
    let mut child = spawn_child(&db);
    if !wait_locked(&mut child) {
        // Environment can't advisory-lock across processes (rare) — don't fail the
        // suite on an environmental limitation.
        let _ = child.kill();
        let _ = child.wait();
        let _ = std::fs::remove_file(&db);
        eprintln!("child never reported the lock; skipping (no cross-process locking here)");
        return;
    }

    // A reader with a generous busy_timeout must WAIT for the child to commit
    // (≈ HOLD_MS) rather than fail immediately.
    let mut reader = Connection::open(&db).expect("reader open");
    reader.execute("PRAGMA busy_timeout=5000").unwrap();
    let start = Instant::now();
    let res = reader.query("SELECT v FROM t WHERE id = 1");
    let waited = start.elapsed();

    let _ = child.wait();
    let _ = std::fs::remove_file(&db);

    match res {
        Ok(q) => {
            // Read succeeded — and only after waiting out most of the hold window.
            assert!(
                waited >= Duration::from_millis(HOLD_MS / 2),
                "reader returned in {waited:?}; expected it to wait ~{HOLD_MS}ms for the writer"
            );
            // It sees the committed value (100 + 1).
            assert_eq!(
                q.rows[0][0],
                Value::Integer(101),
                "reader saw stale/torn value"
            );
        }
        Err(Error::Busy) => {
            // Acceptable only if the whole hold window elapsed (heavy platforms):
            // the point is it did NOT fail fast.
            assert!(
                waited >= Duration::from_millis(HOLD_MS / 2),
                "reader failed Busy after only {waited:?}; busy_timeout was not honored"
            );
        }
        Err(e) => panic!("unexpected error: {e}"),
    }
}