8000 test(storage): Wave 3 PR 3 — migration matrix, index corruption, concurrency stress (#215, #216, #217) by adityamukho · Pull Request #255 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content
Dismiss alert
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c4e2b83
docs: add design spec for Wave 2 PR 1 (predicate push-down + mixed ru…
adityamukho May 15, 2026
b1c139d
docs: add Wave 2 PR 1 implementation plan (predicate push-down + mixe…
adityamukho May 15, 2026
c88c8d3
Merge branch 'main' of github.com:project-minigraf/minigraf
adityamukho May 15, 2026
362502e
docs: add design spec for Wave 2 PR 2 cost-based optimizer extensions…
adityamukho May 15, 2026
6aa8466
docs: explain WASM ordering gate at each cfg site in optimizer and spec
adityamukho May 16, 2026
d744a7b
Merge branch 'main' of github.com:project-minigraf/minigraf
adityamukho May 16, 2026
f68a1df
docs: add design spec for SIMD benchmarking (#229)
adityamukho May 16, 2026
e29f4b2
docs: add Wave 2 PR 2 and #229 SIMD benchmarking implementation plans
adityamukho May 16, 2026
c94ab65
Merge branch 'main' of github.com:project-minigraf/minigraf
adityamukho May 16, 2026
6e0e7a7
docs: add Wave 3 reliability & testing design spec
adityamukho May 16, 2026
469273f
docs: add Wave 3 implementation plans (6 PRs)
adityamukho May 16, 2026
40a7e10
Merge branch 'main' of github.com:project-minigraf/minigraf
adityamukho May 16, 2026
e739a55
test(storage): add #215 migration matrix tests (5 cases)
adityamukho May 16, 2026
75d7ae9
test(storage): add #216 index corruption recovery tests (5 cases)
adityamukho May 16, 2026
45977b0
test(concurrency): add #217 stress tests (readers+writer, rollback, t…
adityamukho May 16, 2026
cd5f052
test(storage): strengthen corruption/migration test assertions per re…
adityamukho May 16, 2026
91b2250
style: cargo fmt
adityamukho May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions tests/concurrency_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,143 @@ fn test_rwlock_consistency() {
}
}
}

// ══ Wave 3: #217 concurrency stress tests ════════════════════════════════════

#[test]
fn stress_readers_during_writer() {
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::thread;

let db = Arc::new(minigraf::db::Minigraf::in_memory().unwrap());
let stop = Arc::new(AtomicBool::new(false));

db.execute(r#"(transact [[:seed :k "v"]])"#).unwrap();

let reader_handles: Vec<_> = (0..8)
.map(|_| {
let db = db.clone();
let stop = stop.clone();
thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
let _ = db.execute("(query [:find ?k :where [?e :k ?k]])");
thread::yield_now();
}
})
})
.collect();

let writer = {
let db = db.clone();
let stop = stop.clone();
thread::spawn(move || {
for i in 0..50 {
let _ = db.execute(&format!(r#"(transact [[:w{i} :n {i}]])"#));
}
stop.store(true, Ordering::Relaxed);
})
};

writer.join().expect("writer panicked");
for h in reader_handles {
h.join().expect("reader panicked");
}
}

#[test]
fn failed_write_followed_by_successful_write() {
let db = minigraf::db::Minigraf::in_memory().unwrap();
let bad = db.execute("(transact [[NOT VALID SYNTAX");
assert!(bad.is_err(), "invalid input must return Err");
db.execute(r#"(transact [[:e1 :ok true]])"#).unwrap();
let n = match db
.execute("(query [:find ?e :where [?e :ok true]])")
.unwrap()
{
minigraf::QueryResult::QueryResults { results, .. } => results.len(),
_ => 0,
};
assert_eq!(n, 1, "fact after failed write must be visible");
}

#[test]
fn rollback_after_partial_work() {
let db = minigraf::db::Minigraf::in_memory().unwrap();
{
let mut tx = db.begin_write().unwrap();
tx.execute(r#"(transact [[:a :x 1] [:b :x 2] [:c :x 3]])"#)
.unwrap();
tx.rollback();
}
let n = match db.execute("(query [:find ?v :where [?e :x ?v]])").unwrap() {
minigraf::QueryResult::QueryResults { results, .. } => results.len(),
_ => 0,
};
assert_eq!(n, 0, "rolled-back multi-fact tx must leave no state");
}

#[test]
fn open_write_checkpoint_query_loop_per_thread() {
use std::thread;

let handles: Vec<_> = (0..4)
.map(|thread_id| {
thread::spawn(move || {
let db = minigraf::db::Minigraf::in_memory().unwrap();
for i in 0..20 {
db.execute(&format!(
r#"(transact [[:t{thread_id}e{i} :thread {thread_id}]])"#
))
.unwrap();
}
let n = match db
.execute(&format!(
"(query [:find ?e :where [?e :thread {thread_id}]])"
))
.unwrap()
{
minigraf::QueryResult::QueryResults { results, .. } => results.len(),
_ => 0,
};
assert_eq!(n, 20, "expected 20 facts per thread");
})
})
.collect();

for h in handles {
h.join().expect("thread panicked");
}
}

#[test]
#[ignore]
fn stress_open_write_loop_nightly() {
use std::thread;

let handles: Vec<_> = (0..8)
.map(|tid| {
thread::spawn(move || {
let db = minigraf::db::Minigraf::in_memory().unwrap();
for i in 0..200 {
db.execute(&format!(r#"(transact [[:nt{tid}e{i} :n {i}]])"#))
.unwrap();
}
let n = match db
.execute(&format!("(query [:find ?e :where [?e :n {}]])", 199))
.unwrap()
{
minigraf::QueryResult::QueryResults { results, .. } => results.len(),
_ => 0,
};
assert!(n >= 1, "last fact must be visible in nightly run");
})
})
.collect();

for h in handles {
h.join().expect("thread panicked in nightly stress");
}
}
126 changes: 126 additions & 0 deletions tests/index_corruption_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! Index corruption recovery tests (#216).
#![cfg(not(target_arch = "wasm32"))]

use minigraf::QueryResult;
use minigraf::db::Minigraf;

const PAGE_SIZE: usize = 4096;

fn count_results(r: QueryResult) -> usize {
match r {
QueryResult::QueryResults { results, .. } => results.len(),
_ => 0,
}
}

fn build_valid_db(path: &std::path::Path, n_facts: usize) {
let db = Minigraf::open(path).unwrap();
for i in 0..n_facts {
db.execute(&format!(r#"(transact [[:e{i} :idx {i}]])"#))
.unwrap();
}
db.checkpoint().unwrap();
}

fn corrupt_bytes_at(path: &std::path::Path, offset: u64, len: usize) {
use std::io::{Read, Seek, SeekFrom, Write};
let mut f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.unwrap();
f.seek(SeekFrom::Start(offset)).unwrap();
let mut buf = vec![0u8; len];
f.read_exact(&mut buf).unwrap();
for b in &mut buf {
*b ^= 0xFF;
}
f.seek(SeekFrom::Start(offset)).unwrap();
f.write_all(&buf).unwrap();
f.sync_all().unwrap();
}

#[test]
fn corrupted_header_checksum_does_not_panic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("corrupt_checksum.graph");
build_valid_db(&path, 3);
corrupt_bytes_at(&path, 76, 4);
match Minigraf::open(&path) {
Ok(db) => {
// If the open succeeded despite checksum corruption, facts must still be readable.
let n = count_results(
db.execute("(query [:find ?e :where [?e :idx ?i]])")
.unwrap(),
);
assert_eq!(
n, 3,
"facts must be readable even after header checksum corruption"
);
}
Err(_) => {
// Rejected at open — also acceptable; either way: no panic.
}
}
}

#[test]
fn corrupted_btree_leaf_page_does_not_panic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("corrupt_leaf.graph");
build_valid_db(&path, 5);
let leaf_offset = (PAGE_SIZE * 2) as u64;
corrupt_bytes_at(&path, leaf_offset, 64);
let _ = Minigraf::open(&path);
}

#[test]
fn corrupted_btree_internal_page_does_not_panic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("corrupt_internal.graph");
build_valid_db(&path, 10);
let offset = (PAGE_SIZE * 3) as u64;
corrupt_bytes_at(&path, offset, 128);
let _ = Minigraf::open(&path);
}

#[test]
fn root_page_pointer_mismatch_does_not_panic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("corrupt_root.graph");
build_valid_db(&path, 3);
{
use std::io::{Seek, SeekFrom, Write};
let mut f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(16)).unwrap();
f.write_all(&u64::MAX.to_le_bytes()).unwrap();
}
let _ = Minigraf::open(&path);
}

#[test]
fn query_results_after_non_critical_corruption_match_original() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("recover_check.graph");
{
let db = Minigraf::open(&path).unwrap();
db.execute(r#"(transact [[:alice :name "Alice"]])"#)
.unwrap();
db.execute(r#"(transact [[:bob :name "Bob"]])"#).unwrap();
db.checkpoint().unwrap();
}
corrupt_bytes_at(&path, 84, 16);
match Minigraf::open(&path) {
Ok(db) => {
let n = count_results(
db.execute("(query [:find ?n :where [?e :name ?n]])")
.unwrap(),
);
assert_eq!(
n, 2,
"both facts must be visible after non-critical corruption"
);
}
Err(_) => {}
}
}
107 changes: 107 additions & 0 deletions tests/migration_matrix_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! Migration matrix tests (#215).
#![cfg(not(target_arch = "wasm32"))]

use minigraf::QueryResult;
use minigraf::db::Minigraf;

const PAGE_SIZE: usize = 4096;
const MAGIC_NUMBER: [u8; 4] = *b"MGRF";

fn count_results(r: QueryResult) -> usize {
match r {
QueryResult::QueryResults { results, .. } => results.len(),
_ => 0,
}
}

#[test]
fn v7_round_trip_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.graph");
{
let db = Minigraf::open(&path).unwrap();
db.execute(r#"(transact [[:e1 :name "Alice"]])"#).unwrap();
db.checkpoint().unwrap();
}
let db2 = Minigraf::open(&path).unwrap();
let n = count_results(
db2.execute("(query [:find ?n :where [?e :name ?n]])")
.unwrap(),
);
assert_eq!(n, 1, "v7 round-trip: Alice must survive close/reopen");
}

#[test]
fn v3_empty_migrates_without_error() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v3.graph");
// Build a minimally valid v3 header: magic + version=3 + page_count=1
// All other fields zero (roots=0 means empty index, fact_count=0).
let mut page = vec![0u8; PAGE_SIZE];
page[0..4].copy_from_slice(&MAGIC_NUMBER);
page[4..8].copy_from_slice(&3u32.to_le_bytes()); // version
page[8..16].copy_from_slice(&1u64.to_le_bytes()); // page_count = 1
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(&page).unwrap();
drop(f);
assert!(
Minigraf::open(&path).is_ok(),
"v3 empty file should open without error"
);
}

#[test]
fn corrupt_magic_fails_loudly() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("corrupt.graph");
let mut page = vec![0u8; PAGE_SIZE];
page[0..4].copy_from_slice(b"XXXX");
page[4..8].copy_from_slice(&7u32.to_le_bytes());
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(&page).unwrap();
let result = Minigraf::open(&path);
assert!(result.is_err(), "corrupt magic must produce an error");
let msg = result.err().unwrap().to_string();
assert!(
msg.contains("magic") || msg.contains("invalid") || msg.contains("not a"),
"error message must describe the corrupt magic"
);
}

#[test]
fn unsupported_version_fails_loudly() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("future.graph");
let mut page = vec![0u8; PAGE_SIZE];
page[0..4].copy_from_slice(&MAGIC_NUMBER);
page[4..8].copy_from_slice(&99u32.to_le_bytes());
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(&page).unwrap();
let result = Minigraf::open(&path);
assert!(result.is_err(), "unsupported version must produce an error");
}

#[test]
fn wal_replay_after_migration_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("replay.graph");
{
let db = Minigraf::open(&path).unwrap();
db.execute(r#"(transact [[:e1 :color "red"]])"#).unwrap();
db.checkpoint().unwrap();
}
{
let db = Minigraf::open(&path).unwrap();
db.execute(r#"(transact [[:e2 :color "blue"]])"#).unwrap();
std::mem::forget(db);
}
let db3 = Minigraf::open(&path).unwrap();
let n = count_results(
db3.execute("(query [:find ?c :where [?e :color ?c]])")
.unwrap(),
);
assert_eq!(n, 2, "WAL replay after checkpoint must be idempotent");
}
Loading
0