1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
//! `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}"),
}
}