use mongreldb_core::{Database, OpenOptions};
use std::io::BufRead;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use tempfile::TempDir;
fn sub_exe() -> PathBuf {
if let Ok(p) = std::env::var("CARGO_BIN_EXE_cross_process_lock_sub") {
return PathBuf::from(p);
}
let exe = std::env::current_exe().expect("current_exe");
exe.parent()
.and_then(|deps| deps.parent())
.map(|target| target.join("cross_process_lock_sub"))
.expect("locate cross_process_lock_sub")
}
fn spawn_sub(role: &str, dir: &std::path::Path) -> (std::process::Child, mpsc::Receiver<String>) {
let mut cmd = Command::new(sub_exe());
cmd.arg(role).arg(dir);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn sub");
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let (tx, rx) = mpsc::channel::<String>();
std::thread::spawn(move || {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
}
});
std::thread::spawn(move || {
let reader = std::io::BufReader::new(stderr);
for _line in reader.lines().map_while(Result::ok) {
}
});
(child, rx)
}
fn wait_for_line(rx: &mpsc::Receiver<String>, want: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
let now = Instant::now();
if now >= deadline {
return false;
}
let remaining = deadline - now;
match rx.recv_timeout(remaining) {
Ok(line) if line == want => return true,
Ok(_) => continue,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return false,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return false,
}
}
}
fn pre_create(dir: &std::path::Path) {
let (mut sub, rx) = spawn_sub("create_then_exit", dir);
let line = wait_for_line_with(&rx, |l| l == "CREATED", Duration::from_secs(5))
.expect("create_then_exit");
assert_eq!(line, "CREATED", "expected CREATED, got: {line:?}");
let status = sub.wait().expect("wait create sub");
assert!(status.success(), "create_then_exit failed: {status:?}");
}
fn wait_for_line_with<F: Fn(&str) -> bool>(
rx: &mpsc::Receiver<String>,
pred: F,
timeout: Duration,
) -> Option<String> {
let deadline = Instant::now() + timeout;
loop {
let now = Instant::now();
if now >= deadline {
return None;
}
let remaining = deadline - now;
match rx.recv_timeout(remaining) {
Ok(line) if pred(&line) => return Some(line),
Ok(_) => continue,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return None,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return None,
}
}
}
#[test]
fn fail_fast_default_rejects_concurrent_open() {
let dir = TempDir::new().unwrap();
pre_create(dir.path());
let (mut holder, holder_rx) = spawn_sub("flock_holder", dir.path());
assert!(
wait_for_line(&holder_rx, "READY", Duration::from_secs(5)),
"holder never reported READY"
);
let start = Instant::now();
let result = Database::open(dir.path());
let elapsed = start.elapsed();
assert!(
result.is_err(),
"default open should fail while another process holds the lock"
);
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("locked by another process"),
"expected 'locked by another process' error, got: {err}"
);
assert!(
elapsed < Duration::from_secs(2),
"fail-fast took too long ({elapsed:?}); expected <2s"
);
holder.kill().expect("kill holder");
let _ = holder.wait();
}
#[test]
fn lock_timeout_waits_and_acquires() {
let dir = TempDir::new().unwrap();
pre_create(dir.path());
let (mut holder, holder_rx) = spawn_sub("flock_holder", dir.path());
assert!(
wait_for_line(&holder_rx, "READY", Duration::from_secs(5)),
"holder never READY"
);
let dir_path = dir.path().to_path_buf();
let (done_tx, done_rx) = mpsc::channel::<String>();
let started = Instant::now();
let waiter = std::thread::spawn(move || {
let opts = OpenOptions::default().with_lock_timeout_ms(5000);
let result = Database::open_with_options(&dir_path, opts);
let took = started.elapsed();
match result {
Ok(_) => done_tx.send(format!("OK after {took:?}")).unwrap(),
Err(e) => done_tx.send(format!("ERR after {took:?}: {e}")).unwrap(),
}
});
std::thread::sleep(Duration::from_millis(300));
holder.kill().expect("kill holder");
let _ = holder.wait();
let msg = done_rx
.recv_timeout(Duration::from_secs(6))
.expect("waiter did not finish within budget");
waiter.join().expect("waiter thread panic");
assert!(msg.starts_with("OK"), "got: {msg}");
}
#[test]
fn lock_timeout_expires_with_error() {
let dir = TempDir::new().unwrap();
pre_create(dir.path());
let (mut holder, holder_rx) = spawn_sub("flock_holder", dir.path());
assert!(
wait_for_line(&holder_rx, "READY", Duration::from_secs(5)),
"holder never READY"
);
let dir_path = dir.path().to_path_buf();
let (done_tx, done_rx) = mpsc::channel::<String>();
let started = Instant::now();
let waiter = std::thread::spawn(move || {
let opts = OpenOptions::default().with_lock_timeout_ms(250);
let result = Database::open_with_options(&dir_path, opts);
let took = started.elapsed();
match result {
Ok(_) => done_tx
.send(format!("UNEXPECTED OK after {took:?}"))
.unwrap(),
Err(e) => done_tx.send(format!("ERR after {took:?}: {e}")).unwrap(),
}
});
let msg = done_rx
.recv_timeout(Duration::from_secs(2))
.expect("waiter should error within budget");
waiter.join().expect("waiter thread panic");
assert!(msg.starts_with("ERR"), "got: {msg}");
assert!(
msg.contains("locked by another process") && msg.contains("250ms"),
"expected timeout-shaped error, got: {msg}"
);
holder.kill().expect("kill holder");
let _ = holder.wait();
}
#[test]
fn engine_holder_blocks_in_process_open() {
let dir = TempDir::new().unwrap();
pre_create(dir.path());
let (mut holder, holder_rx) = spawn_sub("engine_holder", dir.path());
assert!(
wait_for_line(&holder_rx, "READY", Duration::from_secs(5)),
"engine_holder never READY"
);
let result = Database::open(dir.path());
assert!(
result.is_err(),
"default open should fail while subprocess holds the lock"
);
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("locked by another process"),
"expected 'locked by another process' error, got: {err}"
);
holder.kill().expect("kill holder");
let _ = holder.wait();
}
#[test]
fn cross_process_writers_all_succeed() {
const N_WRITERS: i64 = 4;
const ROWS_PER_WRITER: i64 = 25;
const LOCK_TIMEOUT_MS: u32 = 5_000;
let dir = TempDir::new().unwrap();
pre_create(dir.path());
let sub_path = std::env::var("CARGO_BIN_EXE_cross_process_lock_sub")
.expect("CARGO_BIN_EXE_cross_process_lock_sub must be set by cargo test");
let mut handles = Vec::new();
for writer_id in 0..N_WRITERS {
let mut cmd = std::process::Command::new(&sub_path);
cmd.arg("writer")
.arg(dir.path())
.arg(writer_id.to_string())
.arg(LOCK_TIMEOUT_MS.to_string())
.arg(ROWS_PER_WRITER.to_string())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("spawn writer");
let stdout = child.stdout.take().expect("writer stdout");
let (tx, rx) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
use std::io::{BufRead, BufReader};
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
}
});
handles.push((child, rx));
}
let mut completed: Vec<i64> = Vec::new();
let mut failed: Vec<(i64, String, Option<i32>)> = Vec::new();
for (mut child, rx) in handles {
let writer_id_line =
wait_for_line_with(&rx, |l| l.starts_with("DONE"), Duration::from_secs(30))
.unwrap_or_else(|| {
failed.push((-1, "writer never reported DONE".to_string(), None));
String::new()
});
let status = child.wait().expect("wait writer");
if !status.success() {
failed.push((
-1,
format!("writer exited non-zero: {status}"),
status.code(),
));
continue;
}
let parsed_id = writer_id_line
.strip_prefix("DONE ")
.and_then(|s| s.parse::<i64>().ok());
if let Some(id) = parsed_id {
completed.push(id);
} else {
failed.push((-1, format!("malformed DONE line: {writer_id_line:?}"), None));
}
}
assert!(
failed.is_empty(),
"writers failed: {failed:?}; expected all {} to succeed with lock_timeout_ms={LOCK_TIMEOUT_MS}",
N_WRITERS
);
assert_eq!(
completed.len() as i64,
N_WRITERS,
"expected {N_WRITERS} writers to complete, got {completed:?}"
);
let db = Database::open(dir.path()).expect("parent open after writers done");
let table = db.table("items").expect("items table");
let count = table.lock().count();
assert_eq!(
count,
(N_WRITERS * ROWS_PER_WRITER) as u64,
"expected {} rows total, got {count}",
N_WRITERS * ROWS_PER_WRITER
);
}