From 49e588311a8a58ee6b27a787e9ec4bbd974051ea Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Sat, 10 Oct 2026 14:57:45 +0530 Subject: [PATCH] fix(wal): never append after a torn WAL entry (#513, v2.x backport) Backport of #514 from v3. WAL replay stops at the first partial or damaged entry, but the writer appended at the end of the file. After a crash during an append, the next session's commits went in after the partial entry, so a second crash before any checkpoint lost them although each was fsynced and reported committed. A failed append in a running process did the same to the next commit through that handle. - WalWriter::open_or_create cuts an existing WAL back to the end of its last valid entry and syncs it before appending (scan_entries, shared with WalReader::read_entries). - After a failed write or sync, the writer refuses every later append with the new WAL-007 (same code as v3). A checkpoint or a reopen starts a clean writer. A rejected oversize fact (WAL-003) does not poison it. Same tests as on v3 (the oversize fact uses v2's MAX_FACT_BYTES). Refs #513 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012wXxZguv7PgKQjMMH4yJ5h --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- README.md | 2 +- docs/ERROR_REFERENCE.md | 18 +++ docs/TEST_COVERAGE.md | 4 +- src/db.rs | 50 +++++- src/error.rs | 9 ++ src/wal.rs | 340 +++++++++++++++++++++++++++++++--------- tests/wal_test.rs | 48 ++++++ 9 files changed, 392 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd112c88..b5e01d121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **A file shorter than one page is refused at open with `STG-045` unless it is a torn first write (#506).** Open treated any file under 4 KiB as a new database and wrote a new header over it, so a database cut below one page by an interrupted copy, or an unrelated file at the path, was silently replaced. Such a file now opens as a new database only when its bytes are zeros or a prefix of the header a new database writes (what a crash during creation can leave); anything else is `STG-045` and the file is not modified. A zero-length file is still a new database. New code `STG-045`, numbered as in v3.0.0, which also fixes this. +- **A commit made after a torn WAL entry is no longer lost on recovery (#513).** Recovery replays the WAL up to its first partial or damaged entry, but the writer appended after the end of the file. After a crash during an append, the next session's commits went in after the partial entry, so a second crash before any checkpoint lost them, although each had been fsynced and reported as committed. A failed append (short write, full disk, I/O error) did the same to the next commit through that handle. Opening a database for writing now cuts the WAL back to the end of its last valid entry, and syncs it, before anything is appended. After a failed append, every later write through that handle fails with the new `WAL-007` until the database is reopened or a `checkpoint()` succeeds. The transaction whose append failed is not applied; after a reopen it is there in full or not at all. Affected every release since v0.5.0. Also fixed in v3.0.0. ## v2.0.4 — 2026-10-08 diff --git a/CLAUDE.md b/CLAUDE.md index b602f1fd1..6309efe1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,7 +174,7 @@ Auto-migrates v1/v2/v3/v4/v5/v6 → v7 on open/checkpoint. ## Test Coverage -**1224 tests** (1216 passing, 8 ignored; unit + integration + doc). +**1231 tests** (1223 passing, 8 ignored; unit + integration + doc). See `docs/TEST_COVERAGE.md` for the full per-file breakdown. **Testing conventions** — see the Testing Conventions section below before writing any tests. diff --git a/README.md b/README.md index 4287b6a5d..79e6dc60a 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ let r2 = pq.execute(&[("tx", BindValue::TxCount(2)), ("entity", BindValue::Entit ```bash cargo run # interactive Datalog REPL -cargo test # run 1224 tests +cargo test # run 1231 tests cargo run < demos/demo_recursive.txt # recursive rules demo ``` diff --git a/docs/ERROR_REFERENCE.md b/docs/ERROR_REFERENCE.md index ef85615ee..c94de6a9e 100644 --- a/docs/ERROR_REFERENCE.md +++ b/docs/ERROR_REFERENCE.md @@ -143,6 +143,7 @@ with no `CodedError` anywhere in its chain. | WAL-004 | Fact serialised size exceeds u32 range | WAL | | WAL-005 | WAL num_facts exceeds platform usize | WAL | | WAL-006 | Failed to delete WAL file | WAL | +| WAL-007 | Earlier WAL write failed on this handle | WAL | | API-001 | Write lock poisoned | Database API | | API-002 | Unexpected command variant in write path | Database API | | API-003 | Attribute must be a keyword | Database API | @@ -2089,6 +2090,23 @@ The WAL is replayed on open and deleted on checkpoint. --- +### WAL-007 Earlier WAL write failed on this handle + +**Error text**: `an earlier write to the WAL failed on this handle; reopen the database or call checkpoint() before writing again` + +**Cause**: A write or fsync of the `.wal` file failed earlier on this handle (for example a full disk or an I/O error), and that write returned its own error. The WAL may now end in a partial entry. Recovery stops at the first partial entry, so anything appended after it would be lost after a crash. Every later write through this WAL fails with WAL-007 instead. + +The transaction whose write failed was not applied in memory. Whether it is durable is unknown: if its entry reached the disk whole, it is replayed when the database is next opened; otherwise it is lost. It is never applied in part. + +**Resolution**: +- Fix the cause (free disk space, check the device), then reopen the database. Opening cuts off a partial entry at the end of the WAL. +- Or call `checkpoint()`. A successful checkpoint writes the committed facts to the `.graph` file and deletes the WAL; the next write starts a new one. The failed transaction is then gone for good. +- If the failed transaction matters, check after reopening whether it is there, and write it again if it is not. + +**Scenario**: The disk fills during `db.execute("(transact ...)")`, which fails with an I/O error. The next `transact` fails with WAL-007 until the database is reopened or `checkpoint()` succeeds. + +--- + ## API — Database API Errors API errors indicate a violated contract in how the public `Minigraf` or diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 896c61601..d3b24084d 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -4,10 +4,12 @@ **Verified**: 2026-10-10 with `cargo test` -**Result**: 1,216 passing tests and 8 ignored tests (1,224 total), including the golden-file corpus (#391) +**Result**: 1,223 passing tests and 8 ignored tests (1,231 total), including the golden-file corpus (#391) Since v2.0.4, `tests/edge_cases_test.rs` checks that a file of 1 to 4,095 bytes is `STG-045`, unchanged and with no WAL, when it is a database cut below one page or foreign bytes, and opens as a new database that takes a write and reads it back after reopen when it is a prefix of the initial header or all zeros (#506). +Since v2.0.4, torn-WAL-tail tests (#513) check that opening a WAL writer cuts off a partial last entry (cut in the checksum, the header or the payload) or an entry with a bad checksum and everything after it, then appends where replay reaches, and leaves an intact WAL byte for byte; that an append that fails part-way makes every later append `WAL-007` while a rejected oversize fact (`WAL-003`, nothing written) does not; through `Minigraf`, that the failed transaction is not applied, later `transact` and `commit` are `WAL-007`, and a `checkpoint()` makes writes work again; and, in `tests/wal_test.rs`, that a commit made after reopening a crash-left WAL with each kind of torn tail survives a second crash. + `tests/property_test.rs` writes random histories (batched transacts, retracts, write transactions, checkpoints) in memory and to a file, and compares random queries (one to three joined patterns, `not` or `not-join`, a comparison predicate, `:as-of`, `:valid-at` or `:any-valid-time`) with a reference evaluator, on the open file and after a reopen; a query error fails the test (#386). The histories leave out the v2.x known issues #371, #435 and #477. `tests/crash_kill_test.rs` kills a child process running a seeded workload under five checkpoint modes at a random point and checks that the reopened file holds exactly the transactions the child logged as committed, or those plus the one in flight, through full-scan, AEVT, AVET, EAVT and `:as-of` queries, again after a second reopen and after a checkpoint plus reopen (#384). The workload leaves out the v2.x known issues #371 and #435. diff --git a/src/db.rs b/src/db.rs index 95d34500a..d0128a085 100644 --- a/src/db.rs +++ b/src/db.rs @@ -531,8 +531,11 @@ impl Minigraf { /// replay the facts on next open. /// /// If the WAL write fails, an error is returned and the in-memory store is - /// left unchanged. The database remains consistent for subsequent in-process - /// operations. + /// left unchanged. The WAL may then end in a partial entry, so later writes + /// through this handle fail with WAL-007 until the database is reopened or + /// a [`checkpoint`](Self::checkpoint) succeeds; reads keep working. Whether + /// the failed transaction is durable is unknown: after a reopen it is there + /// in full or not at all. /// /// Returns `Err` if called from the same thread that holds an active /// `WriteTransaction` (use `tx.execute()` instead). @@ -2005,6 +2008,49 @@ mod tests { ); } + // ── #513: a failed WAL append ───────────────────────────────────────────── + + /// A WAL append that fails part-way leaves its transaction unapplied and + /// refuses later writes with WAL-007, so nothing lands after the partial + /// entry. A checkpoint deletes the WAL and writes work again. + #[test] + fn test_failed_wal_append_refuses_writes_until_checkpoint() { + fn names(db: &Minigraf) -> usize { + match db + .execute("(query [:find ?n :where [?e :name ?n]])") + .unwrap() + { + QueryResult::QueryResults { results, .. } => results.len(), + _ => 0, + } + } + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test.graph"); + let db = Minigraf::open(&db_path).unwrap(); + db.execute("(transact [[:a :name \"A\"]])").unwrap(); + + crate::wal::tear_next_append(9); + db.execute("(transact [[:b :name \"B\"]])") + .expect_err("the torn append fails"); + assert_eq!(names(&db), 1, "the failed transaction is not applied"); + + let err = db + .execute("(transact [[:c :name \"C\"]])") + .expect_err("later writes are refused"); + assert_eq!(err.code(), "WAL-007"); + let mut tx = db.begin_write().unwrap(); + tx.execute("(transact [[:c :name \"C\"]])").unwrap(); + assert_eq!(tx.commit().unwrap_err().code(), "WAL-007"); + assert_eq!(names(&db), 1); + + db.checkpoint().unwrap(); + db.execute("(transact [[:d :name \"D\"]])").unwrap(); + assert_eq!(names(&db), 2); + drop(db); + let db = Minigraf::open(&db_path).unwrap(); + assert_eq!(names(&db), 2, "A and D survive reopen"); + } + // ── file-backed: checkpoint deletes WAL and updates main file ───────────── #[test] diff --git a/src/error.rs b/src/error.rs index 228e9234f..eecd3e75e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -170,6 +170,7 @@ pub(crate) enum ErrorCode { Wal004, Wal005, Wal006, + Wal007, Api001, Api002, Api003, @@ -980,6 +981,12 @@ pub(crate) const REGISTRY: &[(ErrorCode, &str, &str, ErrorCategory)] = &[ "failed to delete WAL file {}: {}", ErrorCategory::Wal, ), + ( + ErrorCode::Wal007, + "WAL-007", + "an earlier write to the WAL failed on this handle; reopen the database or call checkpoint() before writing again", + ErrorCategory::Wal, + ), ( ErrorCode::Api001, "API-001", @@ -1765,6 +1772,7 @@ mod tests { | ErrorCode::Wal004 | ErrorCode::Wal005 | ErrorCode::Wal006 + | ErrorCode::Wal007 | ErrorCode::Api001 | ErrorCode::Api002 | ErrorCode::Api003 @@ -1957,6 +1965,7 @@ mod tests { ErrorCode::Wal004, ErrorCode::Wal005, ErrorCode::Wal006, + ErrorCode::Wal007, ErrorCode::Api001, ErrorCode::Api002, ErrorCode::Api003, diff --git a/src/wal.rs b/src/wal.rs index 3c611c940..916676c25 100644 --- a/src/wal.rs +++ b/src/wal.rs @@ -132,6 +132,9 @@ pub struct WalEntry { pub struct WalWriter { file: File, sync_mode: SyncMode, + /// An append failed, so the file may end in a partial entry. Later + /// appends would land after it, where replay never reaches (#513). + failed: bool, } impl WalWriter { @@ -153,7 +156,11 @@ impl WalWriter { // durable too, or a power loss can lose the whole file (#389). sync_parent_dir(path)?; file.seek(SeekFrom::End(0))?; - return Ok(WalWriter { file, sync_mode }); + return Ok(WalWriter { + file, + sync_mode, + failed: false, + }); } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} Err(e) => return Err(e.into()), @@ -162,7 +169,17 @@ impl WalWriter { // File exists — validate its header and seek to end for appending let mut file = OpenOptions::new().read(true).write(true).open(path)?; match check_wal_header_length(&mut file)? { - WalHeaderState::Present => validate_wal_header(&mut file)?, + WalHeaderState::Present => { + validate_wal_header(&mut file)?; + // A crash during an append can leave a partial entry at the + // end. Replay stops there, so an entry appended after it + // would be lost: cut it off before appending (#513). + let (_, valid_end) = scan_entries(&mut file)?; + if file.metadata()?.len() > valid_end { + file.set_len(valid_end)?; + file.sync_all()?; + } + } // A previous crash landed between this file's creation and its // header write completing; no entry could have been appended // yet, so re-initialize it as a fresh, empty WAL. @@ -174,7 +191,11 @@ impl WalWriter { } } file.seek(SeekFrom::End(0))?; - Ok(WalWriter { file, sync_mode }) + Ok(WalWriter { + file, + sync_mode, + failed: false, + }) } /// The `SyncMode` this writer was opened with. Used only in tests, to @@ -189,9 +210,32 @@ impl WalWriter { /// The entry is written atomically from the caller's perspective: /// a partial write produces a bad CRC32, which the reader discards. /// Then flushes to disk, unless `sync_mode` is `SyncMode::Normal` — see [`SyncMode`]. + /// + /// After a failed write or sync, every later append fails with WAL-007: + /// the file may end in a partial entry, and replay stops there (#513). A + /// new writer (after a checkpoint deletes the WAL, or on reopen) starts + /// clean. Whether the failed entry itself is durable is unknown: it is + /// replayed on reopen if it reached the disk whole, and lost otherwise. pub fn append_entry(&mut self, tx_count: u64, facts: &[Fact]) -> Result<()> { + if self.failed { + bail_coded!(ErrorCode::Wal007); + } let entry_bytes = serialize_entry(tx_count, facts)?; - self.file.write_all(&entry_bytes)?; + let written = self.write_entry(&entry_bytes); + if written.is_err() { + self.failed = true; + } + written + } + + fn write_entry(&mut self, entry_bytes: &[u8]) -> Result<()> { + #[cfg(test)] + if let Some(n) = TEAR_NEXT_APPEND.take() { + self.file + .write_all(&entry_bytes[..n.min(entry_bytes.len())])?; + return Err(io::Error::other("test: torn WAL append").into()); + } + self.file.write_all(entry_bytes)?; match self.sync_mode { SyncMode::Full => self.file.sync_data()?, SyncMode::Normal => {} @@ -234,6 +278,20 @@ impl WalWriter { } } +// Test-only: the next append on this thread writes only the first `n` bytes +// of its entry, then fails, as a short write or a full disk would. +#[cfg(test)] +thread_local! { + static TEAR_NEXT_APPEND: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +/// Make the next WAL append on this thread write `n` bytes and fail. +#[cfg(test)] +pub(crate) fn tear_next_append(n: usize) { + TEAR_NEXT_APPEND.set(Some(n)); +} + // ─── WalReader ────────────────────────────────────────────────────────────── /// Reads and validates WAL entries for crash recovery. @@ -264,95 +322,104 @@ impl WalReader { /// with an invalid CRC32 (partial write) or at EOF. Earlier entries are /// unaffected by a bad entry. pub fn read_entries(&mut self) -> Result> { - self.file.seek(SeekFrom::Start(WAL_HEADER_SIZE as u64))?; - let mut entries = Vec::new(); - - loop { - // Read checksum (4 bytes); EOF here means no more entries - let mut csum_buf = [0u8; 4]; - match self.file.read_exact(&mut csum_buf) { - Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break, - Err(e) => return Err(e.into()), - Ok(()) => {} - } - let expected_csum = u32::from_le_bytes(csum_buf); + Ok(scan_entries(&mut self.file)?.0) + } +} - // Read tx_count (8 bytes) - let mut tx_count_buf = [0u8; 8]; - if self.file.read_exact(&mut tx_count_buf).is_err() { - break; // truncated - } - let tx_count = u64::from_le_bytes(tx_count_buf); +/// Read every valid entry after the header, stopping at the first truncated +/// or corrupt one or at EOF. Also returns the offset where the valid entries +/// end: the end of the header if there are none. +fn scan_entries(file: &mut File) -> Result<(Vec, u64)> { + file.seek(SeekFrom::Start(WAL_HEADER_SIZE as u64))?; + let mut entries = Vec::new(); + let mut valid_end = WAL_HEADER_SIZE as u64; + + loop { + // Read checksum (4 bytes); EOF here means no more entries + let mut csum_buf = [0u8; 4]; + match file.read_exact(&mut csum_buf) { + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break, + Err(e) => return Err(e.into()), + Ok(()) => {} + } + let expected_csum = u32::from_le_bytes(csum_buf); - // Read num_facts (8 bytes) - let mut num_facts_buf = [0u8; 8]; - if self.file.read_exact(&mut num_facts_buf).is_err() { - break; // truncated - } - let num_facts = usize::try_from(u64::from_le_bytes(num_facts_buf)) - .map_err(|_| err_coded!(ErrorCode::Wal005))?; + // Read tx_count (8 bytes) + let mut tx_count_buf = [0u8; 8]; + if file.read_exact(&mut tx_count_buf).is_err() { + break; // truncated + } + let tx_count = u64::from_le_bytes(tx_count_buf); - // Sanity cap: no legitimate entry has more than 1M facts - const MAX_FACTS_PER_ENTRY: usize = 1_000_000; - if num_facts > MAX_FACTS_PER_ENTRY { - break; // treat as corrupt entry - } + // Read num_facts (8 bytes) + let mut num_facts_buf = [0u8; 8]; + if file.read_exact(&mut num_facts_buf).is_err() { + break; // truncated + } + let num_facts = usize::try_from(u64::from_le_bytes(num_facts_buf)) + .map_err(|_| err_coded!(ErrorCode::Wal005))?; - // Maximum fact size to prevent memory exhaustion from large facts - const MAX_FACT_SIZE: usize = 10 * 1024 * 1024; // 10MB + // Sanity cap: no legitimate entry has more than 1M facts + const MAX_FACTS_PER_ENTRY: usize = 1_000_000; + if num_facts > MAX_FACTS_PER_ENTRY { + break; // treat as corrupt entry + } - // Build payload for CRC32 verification - let mut payload = Vec::new(); - payload.extend_from_slice(&tx_count_buf); - payload.extend_from_slice(&num_facts_buf); + // Maximum fact size to prevent memory exhaustion from large facts + const MAX_FACT_SIZE: usize = 10 * 1024 * 1024; // 10MB + + // Build payload for CRC32 verification + let mut payload = Vec::new(); + payload.extend_from_slice(&tx_count_buf); + payload.extend_from_slice(&num_facts_buf); + + // Read each fact + let mut facts = Vec::new(); // grow dynamically instead of pre-allocating + let mut truncated = false; + for _ in 0..num_facts { + let mut len_buf = [0u8; 4]; + if file.read_exact(&mut len_buf).is_err() { + truncated = true; + break; + } + let fact_len = u32::from_le_bytes(len_buf) as usize; + if fact_len > MAX_FACT_SIZE { + truncated = true; + break; + } + payload.extend_from_slice(&len_buf); - // Read each fact - let mut facts = Vec::new(); // grow dynamically instead of pre-allocating - let mut truncated = false; - for _ in 0..num_facts { - let mut len_buf = [0u8; 4]; - if self.file.read_exact(&mut len_buf).is_err() { - truncated = true; - break; - } - let fact_len = u32::from_le_bytes(len_buf) as usize; - if fact_len > MAX_FACT_SIZE { - truncated = true; - break; - } - payload.extend_from_slice(&len_buf); + let mut fact_bytes = vec![0u8; fact_len]; + if file.read_exact(&mut fact_bytes).is_err() { + truncated = true; + break; + } + payload.extend_from_slice(&fact_bytes); - let mut fact_bytes = vec![0u8; fact_len]; - if self.file.read_exact(&mut fact_bytes).is_err() { + match postcard::from_bytes::(&fact_bytes) { + Ok(f) => facts.push(f), + Err(_) => { truncated = true; break; } - payload.extend_from_slice(&fact_bytes); - - match postcard::from_bytes::(&fact_bytes) { - Ok(f) => facts.push(f), - Err(_) => { - truncated = true; - break; - } - } - } - - if truncated { - break; } + } - // Verify CRC32 over the full payload - let actual_csum = crc32fast::hash(&payload); - if expected_csum != actual_csum { - break; // corrupted entry — stop here - } + if truncated { + break; + } - entries.push(WalEntry { tx_count, facts }); + // Verify CRC32 over the full payload + let actual_csum = crc32fast::hash(&payload); + if expected_csum != actual_csum { + break; // corrupted entry — stop here } - Ok(entries) + entries.push(WalEntry { tx_count, facts }); + valid_end = file.stream_position()?; } + + Ok((entries, valid_end)) } // ─── Unit tests ───────────────────────────────────────────────────────────── @@ -624,6 +691,125 @@ mod tests { assert_eq!(entries[0].tx_count, 1); } + // ── #513: torn tail and failed appends ───────────────────────────────── + + /// The bytes of a WAL holding entries `1..=n`. + fn wal_with_entries(path: &Path, n: u64) -> Vec { + let mut writer = WalWriter::open_or_create(path, SyncMode::Full).unwrap(); + for tx in 1..=n { + let fact = make_fact(Uuid::new_v4(), ":name", Value::Integer(tx as i64), tx); + writer.append_entry(tx, &[fact]).unwrap(); + } + drop(writer); + std::fs::read(path).unwrap() + } + + fn tx_counts(path: &Path) -> Vec { + let mut reader = WalReader::open(path).unwrap(); + let entries = reader.read_entries().unwrap(); + entries.iter().map(|e| e.tx_count).collect() + } + + #[test] + fn test_wal_writer_cuts_off_torn_tail_before_appending() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.wal"); + let two = wal_with_entries(&path, 2); + let one_len = { + let p = dir.path().join("one.wal"); + wal_with_entries(&p, 1).len() + }; + let second = two.len() - one_len; + for cut in [1, 4, 12, 20, second - 1] { + std::fs::write(&path, &two[..one_len + cut]).unwrap(); + let mut writer = WalWriter::open_or_create(&path, SyncMode::Full).unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().len(), + one_len as u64, + "the partial entry is cut off on open" + ); + let fact = make_fact(Uuid::new_v4(), ":name", Value::Integer(7), 7); + writer.append_entry(7, &[fact]).unwrap(); + drop(writer); + assert_eq!(tx_counts(&path), [1, 7], "the new entry is reachable"); + } + } + + #[test] + fn test_wal_writer_cuts_off_entry_with_bad_checksum() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.wal"); + let mut bytes = wal_with_entries(&path, 3); + let one_len = { + let p = dir.path().join("one.wal"); + wal_with_entries(&p, 1).len() + }; + // Damage entry 2; entry 3 after it is unreachable and goes too. + bytes[one_len] ^= 0xFF; + std::fs::write(&path, &bytes).unwrap(); + let writer = WalWriter::open_or_create(&path, SyncMode::Full).unwrap(); + drop(writer); + assert_eq!(std::fs::metadata(&path).unwrap().len(), one_len as u64); + assert_eq!(tx_counts(&path), [1]); + } + + #[test] + fn test_wal_writer_keeps_an_intact_wal() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.wal"); + let bytes = wal_with_entries(&path, 3); + let writer = WalWriter::open_or_create(&path, SyncMode::Full).unwrap(); + drop(writer); + assert!(std::fs::read(&path).unwrap() == bytes, "nothing to cut"); + } + + #[test] + fn test_wal_failed_append_refuses_later_appends_with_wal_007() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.wal"); + let mut writer = WalWriter::open_or_create(&path, SyncMode::Full).unwrap(); + let fact = |tx: u64| make_fact(Uuid::new_v4(), ":name", Value::Integer(1), tx); + writer.append_entry(1, &[fact(1)]).unwrap(); + + tear_next_append(10); + writer + .append_entry(2, &[fact(2)]) + .expect_err("the torn append fails"); + let err: crate::error::MinigrafError = writer + .append_entry(3, &[fact(3)]) + .expect_err("later appends are refused") + .into(); + assert_eq!(err.code(), "WAL-007"); + drop(writer); + assert_eq!(tx_counts(&path), [1], "nothing after the torn entry"); + + // A new writer cuts off the partial entry and appends normally. + let mut writer = WalWriter::open_or_create(&path, SyncMode::Full).unwrap(); + writer.append_entry(4, &[fact(4)]).unwrap(); + drop(writer); + assert_eq!(tx_counts(&path), [1, 4]); + } + + #[test] + fn test_wal_rejected_entry_does_not_poison_the_writer() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.wal"); + let mut writer = WalWriter::open_or_create(&path, SyncMode::Full).unwrap(); + let huge = make_fact( + Uuid::new_v4(), + ":name", + Value::String("x".repeat(MAX_FACT_BYTES + 1)), + 1, + ); + writer + .append_entry(1, &[huge]) + .expect_err("WAL-003, before anything is written"); + let fact = make_fact(Uuid::new_v4(), ":name", Value::Integer(1), 2); + writer.append_entry(2, &[fact]).unwrap(); + drop(writer); + assert_eq!(tx_counts(&path), [2]); + } + #[test] fn test_wal_delete_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/wal_test.rs b/tests/wal_test.rs index 68e76fdf3..eaef5167a 100644 --- a/tests/wal_test.rs +++ b/tests/wal_test.rs @@ -741,6 +741,54 @@ fn wal_corrupt_tail_never_applied() { assert_eq!(names.len(), 1, "fake entry must not create phantom facts"); } +/// A crash during an append leaves a torn entry at the end of the WAL. The +/// next session's commits must not be appended after it, or recovery stops +/// at the torn entry and loses them after a second crash (#513). +#[test] +fn wal_commit_after_torn_tail_survives_second_crash() { + // A second entry to tear: the WAL of a session that wrote two facts. + let (_template_dir, template_path, one_entry) = setup_db_with_one_fact(); + run_crashing_child( + &template_path, + 1000, + &[r#"(transact [[:e9 :name "Torn"]])"#], + CrashTx::Implicit, + ); + let two_entries = read_wal_bytes(&template_path); + let second_entry = &two_entries[one_entry.len()..]; + assert!(second_entry.len() > 12, "second entry too short to tear"); + let mut bad_crc = second_entry.to_vec(); + bad_crc[0] ^= 0xFF; + + let tails: [(&str, Vec); 4] = [ + ("partial checksum", second_entry[..2].to_vec()), + ("partial header", second_entry[..10].to_vec()), + ( + "partial payload", + second_entry[..second_entry.len() - 3].to_vec(), + ), + ("bad checksum", bad_crc), + ]; + for (name, tail) in tails { + let (_dir, db_path, mut torn) = setup_db_with_one_fact(); + torn.extend_from_slice(&tail); + write_wal_bytes(&db_path, &torn); + run_crashing_child( + &db_path, + 1000, + &[r#"(transact [[:e2 :name "Bob"]])"#], + CrashTx::Implicit, + ); + let mut names = query_names(&db_path); + names.sort(); + assert_eq!( + names, + ["Alice", "Bob"], + "a commit after a torn tail must survive ({name})" + ); + } +} + // ══ #214 lock-leak tests ══════════════════════════════════════════════════ #[test]