use std::collections::HashMap;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use graphdblite::{Database, GraphError, NodeId, Value};
fn wait_child(kind: &str, child: std::process::Child) -> Result<(), String> {
let output = child.wait_with_output().expect("wait child");
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!(
"{kind} child failed: {}\n--- child stderr ---\n{}\n--- end stderr ---",
output.status,
stderr.trim_end()
))
}
fn is_busy(err: &GraphError) -> bool {
matches!(
err,
GraphError::Storage {
source: rusqlite::Error::SqliteFailure(e, _),
..
} if matches!(
e.code,
rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
)
)
}
fn with_busy_retry<T>(label: &str, mut f: impl FnMut() -> Result<T, GraphError>) -> T {
let deadline = Instant::now() + Duration::from_secs(120);
let mut attempt = 0u32;
loop {
match f() {
Ok(v) => return v,
Err(e) if is_busy(&e) => {
if Instant::now() >= deadline {
panic!("{label}: SQLITE_BUSY after 120 s and {attempt} attempts: {e}");
}
attempt += 1;
let backoff_ms = std::cmp::min(50 + attempt as u64 * 10, 500);
std::thread::sleep(Duration::from_millis(backoff_ms));
}
Err(e) => panic!("{label}: non-busy error: {e}"),
}
}
}
#[test]
fn concurrent_writers() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("concurrent.db");
{
let _db = Database::open(&path).unwrap();
}
let num_writers = 4;
let nodes_per_writer = 25;
let mut children = Vec::new();
for writer_id in 0..num_writers {
let child = Command::new(std::env::current_exe().unwrap())
.arg("--ignored")
.arg("--exact")
.arg("child_writer")
.env("GRAPHDB_TEST_PATH", path.to_str().unwrap())
.env("GRAPHDB_WRITER_ID", writer_id.to_string())
.env("GRAPHDB_NODES_COUNT", nodes_per_writer.to_string())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn child process");
children.push(child);
}
for child in children {
if let Err(msg) = wait_child("writer", child) {
panic!("{msg}");
}
}
let mut db = Database::open(&path).unwrap();
let tx = db.read_tx().unwrap();
let all_nodes = tx.find_nodes_by_label("Writer").unwrap();
assert_eq!(
all_nodes.len(),
(num_writers * nodes_per_writer) as usize,
"expected {} nodes, found {}",
num_writers * nodes_per_writer,
all_nodes.len()
);
tx.commit().unwrap();
}
#[test]
#[ignore]
fn child_writer() {
let path = match std::env::var("GRAPHDB_TEST_PATH") {
Ok(p) => p,
Err(_) => return, };
let writer_id: u32 = std::env::var("GRAPHDB_WRITER_ID").unwrap().parse().unwrap();
let count: u32 = std::env::var("GRAPHDB_NODES_COUNT")
.unwrap()
.parse()
.unwrap();
let mut db = Database::open(&path).unwrap();
for i in 0..count {
with_busy_retry("child_writer iter", || {
let tx = db.write_tx()?;
tx.create_node("Writer", {
let mut props = HashMap::new();
props.insert("writer_id".to_string(), Value::I64(writer_id as i64));
props.insert("seq".to_string(), Value::I64(i as i64));
props
})?;
tx.commit()?;
Ok(())
});
}
}
#[test]
fn stress_writers_with_readers() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stress.db");
{
let _db = Database::open(&path).unwrap();
}
let num_writers = 6u32;
let num_readers = 4u32;
let chains_per_writer = 10u32;
let nodes_per_chain = 8u32;
let reader_duration_secs = 2u64;
let mut children = Vec::new();
for writer_id in 0..num_writers {
let child = Command::new(std::env::current_exe().unwrap())
.arg("--ignored")
.arg("--exact")
.arg("stress_child_writer")
.env("GRAPHDB_TEST_PATH", path.to_str().unwrap())
.env("GRAPHDB_WRITER_ID", writer_id.to_string())
.env("GRAPHDB_CHAINS", chains_per_writer.to_string())
.env("GRAPHDB_CHAIN_LEN", nodes_per_chain.to_string())
.stderr(Stdio::piped())
.spawn()
.expect("spawn writer");
children.push(("writer", child));
}
for reader_id in 0..num_readers {
let child = Command::new(std::env::current_exe().unwrap())
.arg("--ignored")
.arg("--exact")
.arg("stress_child_reader")
.env("GRAPHDB_TEST_PATH", path.to_str().unwrap())
.env("GRAPHDB_READER_ID", reader_id.to_string())
.env("GRAPHDB_READ_SECS", reader_duration_secs.to_string())
.stderr(Stdio::piped())
.spawn()
.expect("spawn reader");
children.push(("reader", child));
}
for (kind, child) in children {
if let Err(msg) = wait_child(kind, child) {
panic!("{msg}");
}
}
let mut db = Database::open(&path).unwrap();
let tx = db.read_tx().unwrap();
let accounts = tx.find_nodes_by_label("Account").unwrap();
let expected_nodes = (num_writers * chains_per_writer * nodes_per_chain) as usize;
assert_eq!(
accounts.len(),
expected_nodes,
"expected {expected_nodes} Account nodes after stress, found {}",
accounts.len()
);
let edges = tx
.query("MATCH (a:Account)-[:LINKS]->(b:Account) RETURN a.__id, b.__id")
.unwrap();
let expected_edges = (num_writers * chains_per_writer * (nodes_per_chain - 1)) as usize;
assert_eq!(
edges.len(),
expected_edges,
"expected {expected_edges} :LINKS edges, found {}",
edges.len()
);
tx.commit().unwrap();
}
#[test]
#[ignore]
fn stress_child_writer() {
let path = match std::env::var("GRAPHDB_TEST_PATH") {
Ok(p) => p,
Err(_) => return,
};
let writer_id: u32 = std::env::var("GRAPHDB_WRITER_ID").unwrap().parse().unwrap();
let chains: u32 = std::env::var("GRAPHDB_CHAINS").unwrap().parse().unwrap();
let chain_len: u32 = std::env::var("GRAPHDB_CHAIN_LEN").unwrap().parse().unwrap();
let mut db = Database::open(&path).unwrap();
for chain in 0..chains {
with_busy_retry("stress_child_writer chain", || {
let tx = db.write_tx()?;
let mut ids: Vec<NodeId> = Vec::with_capacity(chain_len as usize);
for seq in 0..chain_len {
let mut props = HashMap::new();
props.insert("writer_id".to_string(), Value::I64(writer_id as i64));
props.insert("chain".to_string(), Value::I64(chain as i64));
props.insert("seq".to_string(), Value::I64(seq as i64));
let id = tx.create_node("Account", props)?;
ids.push(id);
}
for win in ids.windows(2) {
tx.create_edge(win[0], win[1], "LINKS", HashMap::new())?;
}
tx.commit()?;
Ok(())
});
}
}
#[test]
#[ignore]
fn stress_child_reader() {
let path = match std::env::var("GRAPHDB_TEST_PATH") {
Ok(p) => p,
Err(_) => return,
};
let secs: u64 = std::env::var("GRAPHDB_READ_SECS").unwrap().parse().unwrap();
let mut db = Database::open(&path).unwrap();
let deadline = Instant::now() + Duration::from_secs(secs);
let mut last_count = 0usize;
let mut iterations = 0u64;
while Instant::now() < deadline {
let (nodes, edges) = with_busy_retry("stress_child_reader iter", || {
let tx = db.read_tx()?;
let nodes = tx.find_nodes_by_label("Account")?;
let edges =
tx.query("MATCH (a:Account)-[:LINKS]->(b:Account) RETURN a.__id, b.__id")?;
tx.commit()?;
Ok((nodes, edges))
});
assert!(
edges.len() < nodes.len() || nodes.is_empty(),
"edges ({}) >= nodes ({}) — torn write?",
edges.len(),
nodes.len()
);
assert!(
nodes.len() >= last_count,
"node count regressed: {} -> {}",
last_count,
nodes.len()
);
last_count = nodes.len();
iterations += 1;
}
assert!(
iterations > 5,
"only {iterations} read iterations completed"
);
}