8000 test(wal): Wave 3 PR 2 — WAL crash-recovery matrix, fault-injection, fuzz targets (#209, #210, #214) by adityamukho · Pull Request #257 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
minigraf = { path = ".." }
tempfile = "3"

[[bin]]
name = "wal_entry"
Expand Down
Binary file added fuzz/corpus/btree_page/seed_v7_3page.bin
Binary file not shown.
Binary file added fuzz/corpus/fact_page/seed_v7_2page.bin
Binary file not shown.
Binary file added fuzz/corpus/file_header/seed_bad_magic.bin
Binary file not shown.
1 change: 1 addition & 0 deletions fuzz/corpus/file_header/seed_truncated.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
MGRF
Binary file added fuzz/corpus/file_header/seed_v7.bin
Binary file not shown.
Binary file added fuzz/corpus/wal_entry/seed_empty.bin
Binary file not shown.
1 change: 1 addition & 0 deletions fuzz/corpus/wal_entry/seed_truncated.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
MWAL
17 changes: 15 additions & 2 deletions fuzz/fuzz_targets/btree_page.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|_data: &[u8]| {
// Stub — implemented in Wave 3 PR 2
fuzz_target!(|data: &[u8]| {
let dir = match tempfile::tempdir() {
Ok(d) => d,
Err(_) => return,
};
let path = dir.path().join("fuzz.graph");
let mut content = vec![0u8; 4096 * 3];
content[0..4].copy_from_slice(b"MGRF");
content[4..8].copy_from_slice(&7u32.to_le_bytes());
let copy_len = data.len().min(4096);
content[4096 * 2..4096 * 2 + copy_len].copy_from_slice(&data[..copy_len]);
if std::fs::write(&path, &content).is_err() {
return;
}
let _ = minigraf::db::Minigraf::open(&path);
});
17 changes: 15 additions & 2 deletions fuzz/fuzz_targets/fact_page.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|_data: &[u8]| {
// Stub — implemented in Wave 3 PR 2
fuzz_target!(|data: &[u8]| {
let dir = match tempfile::tempdir() {
Ok(d) => d,
Err(_) => return,
};
let path = dir.path().join("fuzz.graph");
let mut content = vec![0u8; 4096 * 2];
content[0..4].copy_from_slice(b"MGRF");
content[4..8].copy_from_slice(&7u32.to_le_bytes());
let copy_len = data.len().min(4096);
content[4096..4096 + copy_len].copy_from_slice(&data[..copy_len]);
if std::fs::write(&path, &content).is_err() {
return;
}
let _ = minigraf::db::Minigraf::open(&path);
});
15 changes: 13 additions & 2 deletions fuzz/fuzz_targets/file_header.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
#![no_main]
use libfuzzer_sys::fuzz_targe 8BD0 t;

fuzz_target!(|_data: &[u8]| {
// Stub — implemented in Wave 3 PR 2
fuzz_target!(|data: &[u8]| {
let dir = match tempfile::tempdir() {
Ok(d) => d,
Err(_) => return,
};
let path = dir.path().join("fuzz.graph");
let mut page = vec![0u8; 4096];
let copy_len = data.len().min(4096);
page[..copy_len].copy_from_slice(&data[..copy_len]);
if std::fs::write(&path, &page).is_err() {
return;
}
let _ = minigraf::db::Minigraf::open(&path);
});
13 changes: 11 additions & 2 deletions fuzz/fuzz_targets/wal_entry.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|_data: &[u8]| {
// Stub — implemented in Wave 3 PR 2
fuzz_target!(|data: &[u8]| {
let dir = match tempfile::tempdir() {
Ok(d) => d,
Err(_) => return,
};
let db_path = dir.path().join("fuzz.graph");
let wal_path = dir.path().join("fuzz.graph.wal");
if std::fs::write(&wal_path, data).is_err() {
return;
}
let _ = minigraf::db::Minigraf::open(&db_path);
});
61 changes: 61 additions & 0 deletions src/storage/persistent_facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2081,4 +2081,65 @@ mod tests {
assert!(!backend.is_new(), "reopened file should not be new");
drop(backend);
}

// ══ Wave 3: #214 fault-injection unit tests ═══════════════════════════

mod fault_injection_tests {
use super::*;
use crate::storage::backend::MemoryBackend;
use crate::storage::backend::fault_inject::{FaultConfig, FaultInjectingBackend};

fn make_pfs_with_config() -> (
PersistentFactStorage<FaultInjectingBackend<MemoryBackend>>,
std::sync::Arc<std::sync::Mutex<FaultConfig>>,
) {
let (backend, config) = FaultInjectingBackend::with_config(MemoryBackend::new());
let pfs = PersistentFactStorage::new(backend, 16).unwrap();
(pfs, config)
}

fn stage_fact(pfs: &mut PersistentFactStorage<FaultInjectingBackend<MemoryBackend>>) {
let entity = Uuid::new_v4();
pfs.storage()
.transact(
vec![(entity, ":test/attr".to_string(), Value::Boolean(true))],
None,
)
.unwrap();
pfs.mark_dirty();
}

#[test]
fn save_returns_error_when_write_fails() {
let (mut pfs, config) = make_pfs_with_config();
stage_fact(&mut pfs);
config.lock().unwrap().fail_write_after = Some(0);
let result = pfs.save();
assert!(
result.is_err(),
"save must return Err when write_page fails"
);
}

#[test]
fn save_returns_error_when_sync_fails() {
let (mut pfs, config) = make_pfs_with_config();
stage_fact(&mut pfs);
config.lock().unwrap().fail_sync_after = Some(0);
let result = pfs.save();
assert!(result.is_err(), "save must return Err when sync fails");
}

#[test]
fn save_error_message_is_non_empty() {
let (mut pfs, config) = make_pfs_with_config();
stage_fact(&mut pfs);
config.lock().unwrap().fail_sync_after = Some(0);
let err = pfs.save().unwrap_err();
assert!(
!err.to_string().is_empty(),
"error message must not be empty"
);
}
}
}
207 changes: 207 additions & 0 deletions tests/wal_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,213 @@ fn test_implicit_tx_execute_survives_replay() {
);
}

// ══ Wave 3: #209 WAL crash-recovery matrix ════════════════════════════════

fn read_wal_bytes(db_path: &std::path::Path) -> Vec<u8> {
std::fs::read(wal_path_for(db_path)).unwrap_or_default()
}

fn write_wal_bytes(db_path: &std::path::Path, bytes: &[u8]) {
std::fs::write(wal_path_for(db_path), bytes).unwrap();
}

fn setup_db_with_one_fact() -> (tempfile::TempDir, std::path::PathBuf, Vec<u8>) {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
db.execute(r#"(transact [[:e1 :name "Alice"]])"#).unwrap();
std::mem::forget(db);
}
let wal_bytes = read_wal_bytes(&db_path);
(dir, db_path, wal_bytes)
}

fn query_names(db_path: &std::path::Path) -> Vec<String> {
let db = minigraf::db::Minigraf::open(db_path).unwrap();
match db
.execute("(query [:find ?n :where [?e :name ?n]])")
.unwrap()
{
minigraf::QueryResult::QueryResults { results, .. } => results
.into_iter()
.flatten()
.filter_map(|v| match v {
minigraf::Value::String(s) => Some(s),
_ => None,
})
.collect(),
_ => vec![],
}
}

#[test]
fn wal_recover_truncated_length_header() {
let (_dir, db_path, wal_bytes) = setup_db_with_one_fact();
assert!(!wal_bytes.is_empty(), "WAL should have content");
write_wal_bytes(&db_path, &wal_bytes[..wal_bytes.len() / 2]);
let names = query_names(&db_path);
assert_eq!(names.len(), 0, "partial WAL entry must not be applied");
}

#[test]
fn wal_recover_truncated_payload() {
let (_dir, db_path, wal_bytes) = setup_db_with_one_fact();
let truncation_point = (wal_bytes.len() * 3) / 4;
write_wal_bytes(&db_path, &wal_bytes[..truncation_point]);
let names = query_names(&db_path);
assert_eq!(
names.len(),
0,
"entry with truncated payload must not be applied"
);
}

#[test]
fn wal_recover_bad_checksum_second_entry() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
db.execute(r#"(transact [[:e1 :name "Alice"]])"#).unwrap();
db.execute(r#"(transact [[:e2 :name "Bob"]])"#).unwrap();
std::mem::forget(db);
}
let mut wal_bytes = read_wal_bytes(&db_path);
assert!(wal_bytes.len() > 36, "WAL too short to corrupt");
let n = wal_bytes.len();
wal_bytes[n - 4] ^= 0xFF;
wal_bytes[n - 3] ^= 0xFF;
write_wal_bytes(&db_path, &wal_bytes);
let names = query_names(&db_path);
assert_eq!(
names.len(),
1,
"only the entry before bad checksum should replay"
);
assert!(names.contains(&"Alice".to_string()), "Alice should survive");
}

#[test]
fn wal_recover_committed_tx_crash_before_checkpoint() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
let mut tx = db.begin_write().unwrap();
tx.execute(r#"(transact [[:e1 :name "Charlie"]])"#).unwrap();
tx.commit().unwrap();
std::mem::forget(db);
}
let names = query_names(&db_path);
assert_eq!(
names.len(),
1,
"committed tx must survive crash before checkpoint"
);
assert!(
names.contains(&"Charlie".to_string()),
"Charlie must be present"
);
}

#[test]
fn wal_recover_rollback_crash() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
let mut tx = db.begin_write().unwrap();
tx.execute(r#"(transact [[:e1 :name "Dave"]])"#).unwrap();
tx.rollback();
std::mem::forget(db);
}
let names = query_names(&db_path);
assert_eq!(
names.len(),
0,
"rolled-back fact must not appear after crash"
);
}

#[test]
fn wal_recover_multiple_committed_corrupt_tail() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
db.execute(r#"(transact [[:e1 :name "Eve"]])"#).unwrap();
db.execute(r#"(transact [[:e2 :name "Frank"]])"#).unwrap();
std::mem::forget(db);
}
let mut wal_bytes = read_wal_bytes(&db_path);
wal_bytes.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x00]);
write_wal_bytes(&db_path, &wal_bytes);
let names = query_names(&db_path);
assert_eq!(
names.len(),
2,
"both valid entries must replay; junk tail is discarded"
);
}

#[test]
fn wal_corrupt_tail_never_applied() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.graph");
{
let db = minigraf::db::Minigraf::open(&db_path).unwrap();
db.execute(r#"(transact [[:e1 :name "Grace"]])"#).unwrap();
std::mem::forget(db);
}
let mut wal_bytes = read_wal_bytes(&db_path);
let mut fake_entry: Vec<u8> = Vec::new();
fake_entry.extend_from_slice(&0u32.to_le_bytes());
fake_entry.extend_from_slice(&999u64.to_le_bytes());
fake_entry.extend_from_slice(&1000u64.to_le_bytes());
wal_bytes.extend_from_slice(&fake_entry);
write_wal_bytes(&db_path, &wal_bytes);
let names = query_names(&db_path);
assert!(names.contains(&"Grace".to_string()), "Grace should replay");
assert_eq!(names.len(), 1, "fake entry must not create phantom facts");
}

// ══ Wave 3: #214 lock-leak tests ═════════════════════════════════════════

#[test]
fn write_lock_not_leaked_after_rollback() {
let db = minigraf::db::Minigraf::in_memory().unwrap();
let mut tx1 = db.begin_write().unwrap();
tx1.execute(r#"(transact [[:e1 :name "Temp"]])"#).unwrap();
tx1.rollback();
let mut tx2 = db.begin_write().unwrap();
tx2.execute(r#"(transact [[:e2 :name "Perm"]])"#).unwrap();
tx2.commit().unwrap();
let n = count_results(
db.execute("(query [:find ?n :where [?e :name ?n]])")
.unwrap(),
);
assert_eq!(n, 1, "only committed fact should be visible");
}

#[test]
fn write_state_clean_after_drop() {
let db = minigraf::db::Minigraf::in_memory().unwrap();
{
let mut tx = db.begin_write().unwrap();
tx.execute(r#"(transact [[:e1 :name "Ghost"]])"#).unwrap();
// drop without commit or rollback
}
let mut tx2 = db.begin_write().unwrap();
tx2.execute(r#"(transact [[:e2 :name "Real"]])"#).unwrap();
tx2.commit().unwrap();
let n = count_results(
db.execute("(query [:find ?n :where [?e :name ?n]])")
.unwrap(),
);
assert_eq!(n, 1, "only committed fact visible after dropped tx");
}

// ── 12. V2 file upgrades to V3 on checkpoint ─────────────────────────────────

/// Create a v2-format `.graph` file manually (version field = 2, no
Expand Down
Loading
0