cqlite_core/storage/write_engine/wal.rs
1//! Write-ahead log (WAL) for crash recovery
2//!
3//! Provides durability guarantees for mutations before they reach the memtable.
4//! Every mutation is fsync'd to the WAL before being acknowledged.
5//!
6//! ## WAL Entry Format
7//!
8//! Each entry in the WAL follows this binary format:
9//!
10//! ```text
11//! [u32 LE: entry_length] (4 bytes)
12//! [u32 LE: crc32] (4 bytes)
13//! [bytes: serialized Mutation] (entry_length bytes)
14//! ```
15//!
16//! The CRC32 checksum is computed over the serialized mutation bytes only.
17//! This format allows for:
18//! - Detection of corrupted entries during replay
19//! - Safe truncation at partial writes (crash during append)
20//! - Sequential append with minimal overhead
21//!
22//! ## Memory Budget
23//!
24//! - 4 KB buffer for sequential append (configurable)
25//! - Flushes to disk on explicit sync() or buffer full
26//!
27//! ## Crash Recovery
28//!
29//! On startup, replay() reads all valid entries:
30//! - Corrupted entries: logged as warnings, skipped
31//! - Truncated entries: stop replay (incomplete write)
32//! - Valid entries: returned in order for memtable replay
33
34use crate::error::{Error, Result};
35use crate::storage::write_engine::mutation::{
36 CellOperation, ClusteringKey, Mutation, PartitionKey, PartitionTombstone, RangeTombstone,
37 TableId,
38};
39use crc32fast::Hasher;
40use std::fs::{File, OpenOptions};
41use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
42use std::path::{Path, PathBuf};
43
44/// Maximum plausible WAL entry payload length (16 MiB).
45///
46/// A declared length larger than this signals a torn/garbage tail rather than a
47/// real entry. Used both when scanning for the last valid boundary on open and
48/// as the replay-time sanity bound.
49const MAX_ENTRY_LENGTH: u32 = 16 * 1024 * 1024;
50
51/// Outcome of a WAL crash-recovery replay (issue #1391).
52///
53/// Replaying a WAL is not always lossless: a crash can leave a torn tail, and
54/// on-disk bit-rot can corrupt an entry in the MIDDLE of the log. The previous
55/// `replay()` returned a bare `Ok(Vec<Mutation>)` in every case, so a corrupt
56/// recovery was indistinguishable from a clean one — and the next flush then
57/// truncated the WAL, making the loss permanent and invisible.
58///
59/// This report makes lossiness explicit. Callers MUST consult
60/// [`is_clean`](Self::is_clean) before treating the recovery as complete;
61/// [`WriteEngine`](super::WriteEngine) preserves the raw WAL segment aside and
62/// surfaces the report rather than silently truncating over corruption.
63///
64/// # Recovery posture (fail-fast, then report)
65///
66/// The WAL framing has no sync markers, so once an entry's CRC does not verify
67/// (or its declared length is implausible) the byte offset of the *next* entry
68/// cannot be recovered authoritatively — trusting the corrupt length is exactly
69/// the misalignment bug that decoded arbitrary garbage. Replay therefore matches
70/// Cassandra's `CommitLogReplayer` default: it recovers the valid *prefix*, then
71/// **stops at the first unrecoverable corruption** ([`stopped_early`]) rather
72/// than guessing a resync point. The only exception is a CRC-*valid* entry whose
73/// payload fails to deserialize: the CRC guarantees the entry boundary, so that
74/// single entry is skipped (counted in [`corrupt_entries`]) and replay continues
75/// — an authoritative, non-heuristic resync.
76///
77/// [`stopped_early`]: Self::stopped_early
78/// [`corrupt_entries`]: Self::corrupt_entries
79#[derive(Debug, Clone, Default)]
80pub struct RecoveryReport {
81 /// Mutations recovered, in log order. On corruption this is the valid
82 /// prefix that precedes the first unrecoverable entry.
83 pub mutations: Vec<Mutation>,
84 /// Number of entries that could not be recovered (CRC mismatch, implausible
85 /// length, or CRC-valid-but-undecodable payload).
86 pub corrupt_entries: usize,
87 /// True when replay stopped before end-of-file because it hit corruption it
88 /// could not authoritatively resync past. Any entries after the stop point
89 /// are NOT in [`mutations`](Self::mutations) and are covered by
90 /// [`bytes_skipped`](Self::bytes_skipped).
91 pub stopped_early: bool,
92 /// Number of trailing bytes that were present on disk but not recovered
93 /// (the corrupt entry plus everything after it when `stopped_early`).
94 pub bytes_skipped: u64,
95}
96
97impl RecoveryReport {
98 /// A recovery is clean iff no entry was corrupt and replay reached EOF
99 /// without stopping early. A torn HEADER (an incomplete final append that
100 /// never finished its 8-byte header, so there is provably no complete entry
101 /// to lose) is NOT corruption and keeps a report clean; a complete-header
102 /// entry that cannot be satisfied is corruption and makes a report lossy
103 /// (issue #1391 r5).
104 pub fn is_clean(&self) -> bool {
105 self.corrupt_entries == 0 && !self.stopped_early
106 }
107}
108
109/// Why a sequential WAL scan stopped advancing (issue #1390 + #1391).
110///
111/// Distinguishing a torn tail from mid-stream corruption is the crux of safe
112/// recovery: a torn tail is an interrupted final append (safe to trim), whereas
113/// a CRC mismatch / implausible length / unsatisfiable length on a *complete-
114/// header* entry is *unexpected corruption* whose successors must NOT be
115/// silently trimmed away.
116///
117/// # The torn-tail boundary is the header, not the payload (issue #1391, r5)
118///
119/// A torn tail is trimmable-as-clean ONLY when the trailing entry's 8-byte
120/// header is itself incomplete (< 8 bytes at EOF): there is provably no complete
121/// entry to lose. Once a COMPLETE header is present, an entry that cannot be
122/// satisfied (short payload at EOF, bad CRC, or an implausible length) is
123/// [`Corruption`](Self::Corruption): using only the allowed no-heuristics facts
124/// (header completeness, declared-length vs remaining-bytes, physical-tail
125/// position) an interrupted final write is INDISTINGUISHABLE from a bit-flipped
126/// length whose declared size overshoots EOF and swallows valid, acknowledged
127/// successors. Trusting it to be "just a torn tail" is exactly the silent lossy
128/// trim #1391 eliminates, so a complete-header entry that is dropped is always
129/// preserved + surfaced as lossy.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131enum WalStop {
132 /// Reached end-of-file exactly on an entry boundary — a fully clean log.
133 CleanEof,
134 /// The trailing entry's 8-byte HEADER was cut short (< 8 bytes at EOF): an
135 /// interrupted append that never even finished its header, so there is
136 /// provably no complete entry to lose. This is the only structurally-
137 /// incomplete tail that may be trimmed-as-clean (issue #1391, r5).
138 TornTail,
139 /// A COMPLETE-header entry could not be trusted: it failed CRC, declared an
140 /// implausible length (> [`MAX_ENTRY_LENGTH`]), or declared a length that
141 /// cannot be satisfied by the remaining bytes (short payload at EOF). The
142 /// framing past this point is untrustworthy and the dropped entry may be a
143 /// bit-flipped record swallowing valid successors, so it must be preserved,
144 /// not silently discarded.
145 Corruption,
146}
147
148/// Outcome of a failed [`WriteAheadLog::truncate_checked`], distinguishing a
149/// failure that leaves the WAL intact from one that has already destroyed it
150/// (issue #1392).
151///
152/// `set_len(0)` is the point of no return: once it succeeds the on-disk WAL is
153/// empty and is no longer a replayable recovery marker, even if a subsequent
154/// fsync/seek fails. Swallowing such a post-mutation failure as if the WAL were
155/// still intact would let the caller clear the memtable and report a durable,
156/// replayable WAL that no longer exists — silent data loss.
157#[derive(Debug)]
158pub enum TruncateError {
159 /// Failure at or before `set_len(0)`: the WAL contents were not mutated and
160 /// remain a valid replay marker. Safe to leave the WAL in place.
161 BeforeMutation(Error),
162 /// Failure after `set_len(0)` zeroed the WAL: the WAL is no longer a valid
163 /// replay marker. Callers MUST NOT treat the WAL as intact.
164 AfterMutation(Error),
165}
166
167impl TruncateError {
168 /// Unwrap to the underlying [`Error`], discarding the phase distinction.
169 /// Used by the phase-agnostic [`WriteAheadLog::truncate`] wrapper.
170 pub fn into_inner(self) -> Error {
171 match self {
172 TruncateError::BeforeMutation(e) | TruncateError::AfterMutation(e) => e,
173 }
174 }
175}
176
177impl std::fmt::Display for TruncateError {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 match self {
180 TruncateError::BeforeMutation(e) => {
181 write!(f, "WAL truncate failed before mutation (WAL intact): {e}")
182 }
183 TruncateError::AfterMutation(e) => write!(
184 f,
185 "WAL truncate failed after mutation (WAL contents already zeroed): {e}"
186 ),
187 }
188 }
189}
190
191impl std::error::Error for TruncateError {}
192
193/// Sync directory metadata to ensure file entries are persisted
194///
195/// On POSIX systems this is critical for crash safety - without syncing the
196/// directory, newly created or renamed files may not appear after a crash.
197///
198/// Windows does not allow opening a directory as a file (ERROR_ACCESS_DENIED).
199/// NTFS commits directory metadata together with the contained file's data
200/// when `sync_all` is called on the file itself, so an explicit directory
201/// sync is unnecessary on Windows and we skip it.
202#[cfg(unix)]
203pub(crate) fn sync_directory(dir: &Path) -> Result<()> {
204 let dir_file = File::open(dir)
205 .map_err(|e| Error::Storage(format!("Failed to open directory for sync: {}", e)))?;
206
207 dir_file
208 .sync_all()
209 .map_err(|e| Error::Storage(format!("Failed to sync directory: {}", e)))?;
210
211 Ok(())
212}
213
214#[cfg(not(unix))]
215pub(crate) fn sync_directory(_dir: &Path) -> Result<()> {
216 Ok(())
217}
218
219/// Validate WAL directory path for security
220///
221/// This prevents path traversal attacks and ensures the directory is safe to use.
222///
223/// # Security Checks
224///
225/// - Directory must exist
226/// - Path is canonicalized to resolve symlinks and `..' sequences
227/// - Path must not contain control characters
228///
229/// # Arguments
230///
231/// * `dir` - Directory path to validate
232///
233/// # Errors
234///
235/// Returns an error if validation fails
236fn validate_wal_directory(dir: &Path) -> Result<PathBuf> {
237 // Check directory exists
238 if !dir.exists() {
239 return Err(Error::InvalidPath(format!(
240 "WAL directory does not exist: {:?}",
241 dir
242 )));
243 }
244
245 if !dir.is_dir() {
246 return Err(Error::InvalidPath(format!(
247 "WAL path is not a directory: {:?}",
248 dir
249 )));
250 }
251
252 // Canonicalize to resolve symlinks and '..' sequences
253 let canonical = dir
254 .canonicalize()
255 .map_err(|e| Error::InvalidPath(format!("Failed to canonicalize WAL directory: {}", e)))?;
256
257 // Check for control characters in the path
258 let path_str = canonical.to_string_lossy();
259 if path_str.chars().any(|c| c.is_control()) {
260 return Err(Error::InvalidPath(
261 "WAL directory path contains control characters".to_string(),
262 ));
263 }
264
265 Ok(canonical)
266}
267
268/// Set secure file permissions on Unix platforms
269///
270/// This restricts WAL file access to the owner only (0o600)
271#[cfg(unix)]
272fn set_secure_permissions(file: &File) -> Result<()> {
273 use std::os::unix::fs::PermissionsExt;
274
275 let mut perms = file
276 .metadata()
277 .map_err(|e| Error::Storage(format!("Failed to read file metadata: {}", e)))?
278 .permissions();
279
280 perms.set_mode(0o600);
281
282 file.set_permissions(perms)
283 .map_err(|e| Error::Storage(format!("Failed to set file permissions: {}", e)))?;
284
285 Ok(())
286}
287
288/// Set secure file permissions (no-op on non-Unix platforms)
289#[cfg(not(unix))]
290fn set_secure_permissions(_file: &File) -> Result<()> {
291 // No-op on Windows - NTFS permissions are handled differently
292 Ok(())
293}
294
295/// Legacy `CellOperation` layout (pre-Issue #921).
296///
297/// bincode is positional and NOT self-describing: it encodes an enum as a `u32`
298/// variant index followed by that variant's fields, with no field names and no
299/// length/version prefix. Issue #921 added a `local_deletion_time: Option<i32>`
300/// field to [`CellOperation::Delete`], turning the on-disk shape of that variant
301/// from `{ column }` into `{ column, local_deletion_time }`. A `#[serde(default)]`
302/// attribute does NOT help bincode here — there are simply no bytes for the new
303/// field in an older record, so decoding the new `Delete` reads the bytes of the
304/// following operation as the missing `Option<i32>` and the whole record
305/// misaligns.
306///
307/// This mirror enum reproduces the EXACT pre-#921 variant order and shapes so a
308/// record written by an older binary round-trips. `Delete` differs (no
309/// `local_deletion_time`, pre-#921) and `WriteWithTtl` differs (no per-cell
310/// `local_deletion_time`, pre-#1538). It MUST stay in lockstep with [`CellOperation`]:
311/// variant order is the bincode discriminant, so any reordering / insertion in
312/// the live enum must be reflected here or legacy decoding breaks.
313#[derive(serde::Serialize, serde::Deserialize)]
314enum LegacyCellOperation {
315 Write {
316 column: String,
317 value: crate::types::Value,
318 },
319 WriteWithTtl {
320 column: String,
321 value: crate::types::Value,
322 ttl_seconds: u32,
323 },
324 /// Pre-#921 `Delete` had no `local_deletion_time` field.
325 Delete {
326 column: String,
327 },
328 DeleteRow,
329 WriteComplexElement {
330 column: String,
331 cell_path: Vec<u8>,
332 value: Option<crate::types::Value>,
333 timestamp_micros: i64,
334 ttl_seconds: Option<u32>,
335 local_deletion_time: Option<i32>,
336 is_deleted: bool,
337 },
338 ComplexDeletion {
339 column: String,
340 marked_for_delete_at: i64,
341 local_deletion_time: i32,
342 },
343}
344
345impl From<LegacyCellOperation> for CellOperation {
346 fn from(op: LegacyCellOperation) -> Self {
347 match op {
348 LegacyCellOperation::Write { column, value } => CellOperation::Write { column, value },
349 // Pre-#1538 expiring cell: no surfaced source LDT, so the writer
350 // derives `now + ttl` (historical behavior).
351 LegacyCellOperation::WriteWithTtl {
352 column,
353 value,
354 ttl_seconds,
355 } => CellOperation::WriteWithTtl {
356 column,
357 value,
358 ttl_seconds,
359 local_deletion_time: None,
360 },
361 // Pre-#921 cell tombstone: no surfaced source LDT, so the writer
362 // derives it from the enclosing mutation (historical behavior).
363 LegacyCellOperation::Delete { column } => CellOperation::Delete {
364 column,
365 local_deletion_time: None,
366 },
367 LegacyCellOperation::DeleteRow => CellOperation::DeleteRow,
368 LegacyCellOperation::WriteComplexElement {
369 column,
370 cell_path,
371 value,
372 timestamp_micros,
373 ttl_seconds,
374 local_deletion_time,
375 is_deleted,
376 } => CellOperation::WriteComplexElement {
377 column,
378 cell_path,
379 value,
380 timestamp_micros,
381 ttl_seconds,
382 local_deletion_time,
383 is_deleted,
384 },
385 LegacyCellOperation::ComplexDeletion {
386 column,
387 marked_for_delete_at,
388 local_deletion_time,
389 } => CellOperation::ComplexDeletion {
390 column,
391 marked_for_delete_at,
392 local_deletion_time,
393 },
394 }
395 }
396}
397
398/// Legacy on-disk `Mutation` layout (pre-Issue #764 / pre-Issue #921).
399///
400/// The WAL has no per-record version field; mutations are bincode-serialized
401/// directly and bincode is positional, so the `local_deletion_time` field added
402/// to [`Mutation`] in #764 — and the `local_deletion_time` field added to
403/// [`CellOperation::Delete`] in #921 — both change the byte layout. To keep
404/// recovering WAL records written by an older binary, `decode_mutation` first
405/// tries the current layout and, on failure, falls back to this legacy layout
406/// (which lacks the `Mutation`-level `local_deletion_time` AND uses the pre-#921
407/// [`LegacyCellOperation`] for `operations`), upgrading both to the historical
408/// `None` behavior. The field order here MUST mirror the historical `Mutation`
409/// struct.
410#[derive(serde::Serialize, serde::Deserialize)]
411struct LegacyMutation {
412 table: TableId,
413 partition_key: PartitionKey,
414 clustering_key: Option<ClusteringKey>,
415 operations: Vec<LegacyCellOperation>,
416 timestamp_micros: i64,
417 ttl_seconds: Option<u32>,
418 partition_tombstone: Option<PartitionTombstone>,
419 range_tombstones: Vec<RangeTombstone>,
420}
421
422impl From<LegacyMutation> for Mutation {
423 fn from(m: LegacyMutation) -> Self {
424 Mutation {
425 table: m.table,
426 partition_key: m.partition_key,
427 clustering_key: m.clustering_key,
428 operations: m.operations.into_iter().map(CellOperation::from).collect(),
429 timestamp_micros: m.timestamp_micros,
430 ttl_seconds: m.ttl_seconds,
431 partition_tombstone: m.partition_tombstone,
432 range_tombstones: m.range_tombstones,
433 // Pre-#764 records had no explicit local deletion time; preserving
434 // None means the writer derives it from the timestamp as before.
435 local_deletion_time: None,
436 // Pre-#932 records had no coexisting row tombstone field.
437 row_tombstone: None,
438 // Pre-#1018 records had no per-cell write-timestamp side-channel.
439 cell_write_timestamps: None,
440 }
441 }
442}
443
444/// Intermediate on-disk `Mutation` layout (post-Issue #764, pre-Issue #921).
445///
446/// There are three historical WAL record layouts because the WAL has no
447/// per-record version field and bincode is positional:
448///
449/// - **(A)** pre-#764: no `Mutation`-level `local_deletion_time`, old
450/// `Delete { column }` operations — see [`LegacyMutation`].
451/// - **(B)** post-#764 / pre-#921 (THIS struct): the `Mutation`-level
452/// `local_deletion_time: Option<i32>` trailing field is PRESENT, but the
453/// operations still use the pre-#921 `Delete { column }` shape.
454/// - **(C)** current: `Mutation`-level LDT present AND
455/// `Delete { column, local_deletion_time }`.
456///
457/// Layout (B) records are NOT covered by [`LegacyMutation`] (which lacks the
458/// mutation-level LDT) nor by the current [`Mutation`] (whose `Delete` carries
459/// an extra `Option<i32>`), so without this struct a (B) record would fail to
460/// replay and silently lose its mutation-level local deletion time.
461///
462/// The field order MUST mirror the current [`Mutation`] struct exactly, with
463/// only `operations` swapped to [`LegacyCellOperation`].
464#[derive(serde::Serialize, serde::Deserialize)]
465struct LegacyMutationWithLdt {
466 table: TableId,
467 partition_key: PartitionKey,
468 clustering_key: Option<ClusteringKey>,
469 operations: Vec<LegacyCellOperation>,
470 timestamp_micros: i64,
471 ttl_seconds: Option<u32>,
472 partition_tombstone: Option<PartitionTombstone>,
473 range_tombstones: Vec<RangeTombstone>,
474 /// Mutation-level local deletion time added in #764; preserved on upgrade.
475 local_deletion_time: Option<i32>,
476}
477
478impl From<LegacyMutationWithLdt> for Mutation {
479 fn from(m: LegacyMutationWithLdt) -> Self {
480 Mutation {
481 table: m.table,
482 partition_key: m.partition_key,
483 clustering_key: m.clustering_key,
484 operations: m.operations.into_iter().map(CellOperation::from).collect(),
485 timestamp_micros: m.timestamp_micros,
486 ttl_seconds: m.ttl_seconds,
487 partition_tombstone: m.partition_tombstone,
488 range_tombstones: m.range_tombstones,
489 // Preserve the mutation-level LDT that layout (B) carries; only the
490 // pre-#921 cell `Delete` ops lose their (never-present) LDT to None.
491 local_deletion_time: m.local_deletion_time,
492 // Layout (B) predates #932; no coexisting row tombstone field.
493 row_tombstone: None,
494 // Layout (B) predates #1018; no per-cell write-timestamp side-channel.
495 cell_write_timestamps: None,
496 }
497 }
498}
499
500/// On-disk `Mutation` layout post-Issue #921, pre-Issue #932 (layout (C)).
501///
502/// Issue #932 appended a trailing `row_tombstone: Option<(i64, i32)>` field to
503/// [`Mutation`]. Because the WAL has no per-record version field and bincode is
504/// positional, a record written by a #921-era binary (layout (C): current
505/// `CellOperation` shapes and mutation-level LDT, but NO trailing
506/// `row_tombstone`) has no bytes for the new field. Decoding it as the current
507/// [`Mutation`] runs out of bytes when reading the trailing `Option`. This
508/// mirror reproduces the EXACT layout-(C) field order — identical to the current
509/// `Mutation` minus the new trailing field — so such records still replay,
510/// upgrading `row_tombstone` to `None` (historical behavior).
511///
512/// The field order MUST stay in lockstep with [`Mutation`] (current
513/// [`CellOperation`]), with only the trailing `row_tombstone` omitted.
514#[derive(serde::Serialize, serde::Deserialize)]
515struct PreRowTombstoneMutation {
516 table: TableId,
517 partition_key: PartitionKey,
518 clustering_key: Option<ClusteringKey>,
519 // Layout (C) predates #1538, so its on-disk `WriteWithTtl` is the 3-field
520 // shape. Decode ops through the pre-#1538 mirror (which keeps the post-#921
521 // `Delete { column, local_deletion_time }` shape this layout carries), NOT
522 // the current 4-field `CellOperation`, which would positionally misalign.
523 operations: Vec<PreCellLdtWriteTtlCellOperation>,
524 timestamp_micros: i64,
525 ttl_seconds: Option<u32>,
526 partition_tombstone: Option<PartitionTombstone>,
527 range_tombstones: Vec<RangeTombstone>,
528 local_deletion_time: Option<i32>,
529}
530
531impl From<PreRowTombstoneMutation> for Mutation {
532 fn from(m: PreRowTombstoneMutation) -> Self {
533 Mutation {
534 table: m.table,
535 partition_key: m.partition_key,
536 clustering_key: m.clustering_key,
537 operations: m.operations.into_iter().map(CellOperation::from).collect(),
538 timestamp_micros: m.timestamp_micros,
539 ttl_seconds: m.ttl_seconds,
540 partition_tombstone: m.partition_tombstone,
541 range_tombstones: m.range_tombstones,
542 local_deletion_time: m.local_deletion_time,
543 // Pre-#932 records carry no coexisting row tombstone.
544 row_tombstone: None,
545 // Pre-#1018 records carry no per-cell write-timestamp side-channel.
546 cell_write_timestamps: None,
547 }
548 }
549}
550
551/// On-disk `Mutation` layout post-Issue #932, pre-Issue #1018.
552///
553/// Issue #1018 appended a trailing `cell_write_timestamps:
554/// Option<HashMap<String, i64>>` field to [`Mutation`]. Because the WAL has no
555/// per-record version field and bincode is positional, a record written by a
556/// #932-era binary (layout (D): current op shapes, mutation LDT AND
557/// `row_tombstone`, but NO trailing `cell_write_timestamps`) has no bytes for
558/// the new field, so decoding it as the current [`Mutation`] runs out of bytes.
559/// This mirror reproduces the EXACT layout-(D) field order so such records still
560/// replay, upgrading `cell_write_timestamps` to `None` (historical behavior:
561/// every cell inherits the row timestamp).
562///
563/// The field order MUST stay in lockstep with [`Mutation`], with only the
564/// trailing `cell_write_timestamps` omitted.
565#[derive(serde::Serialize, serde::Deserialize)]
566struct PreCellWriteTimestampsMutation {
567 table: TableId,
568 partition_key: PartitionKey,
569 clustering_key: Option<ClusteringKey>,
570 // Layout (D) predates #1538, so its on-disk `WriteWithTtl` is the 3-field
571 // shape. Decode ops through the pre-#1538 mirror (which keeps the post-#921
572 // `Delete { column, local_deletion_time }` shape this layout carries), NOT
573 // the current 4-field `CellOperation`, which would positionally misalign.
574 operations: Vec<PreCellLdtWriteTtlCellOperation>,
575 timestamp_micros: i64,
576 ttl_seconds: Option<u32>,
577 partition_tombstone: Option<PartitionTombstone>,
578 range_tombstones: Vec<RangeTombstone>,
579 local_deletion_time: Option<i32>,
580 row_tombstone: Option<(i64, i32)>,
581}
582
583impl From<PreCellWriteTimestampsMutation> for Mutation {
584 fn from(m: PreCellWriteTimestampsMutation) -> Self {
585 Mutation {
586 table: m.table,
587 partition_key: m.partition_key,
588 clustering_key: m.clustering_key,
589 operations: m.operations.into_iter().map(CellOperation::from).collect(),
590 timestamp_micros: m.timestamp_micros,
591 ttl_seconds: m.ttl_seconds,
592 partition_tombstone: m.partition_tombstone,
593 range_tombstones: m.range_tombstones,
594 local_deletion_time: m.local_deletion_time,
595 row_tombstone: m.row_tombstone,
596 // Pre-#1018 records carry no per-cell write-timestamp side-channel.
597 cell_write_timestamps: None,
598 }
599 }
600}
601
602/// On-disk cell-op layout immediately before Issue #1538 (i.e. post-#921,
603/// pre-#1538 — the shape [`CellOperation`] had for the entire #921..#1538 span,
604/// covering the post-#932/pre-#1018 (D), post-#921/pre-#932 (C), and
605/// post-#1018/pre-#1538 eras).
606///
607/// Issue #1538 appended a trailing `local_deletion_time: Option<i32>` field to
608/// [`CellOperation::WriteWithTtl`], turning its on-disk shape from
609/// `{ column, value, ttl_seconds }` into `{ column, value, ttl_seconds,
610/// local_deletion_time }`. Because the WAL has no per-record version field and
611/// bincode is positional, a record whose ops were written by a pre-#1538 binary
612/// encodes a 3-field `WriteWithTtl` with NO bytes for the new field, so decoding
613/// it with the current [`CellOperation`] reads the following operation's bytes as
614/// the missing `Option` and misaligns the whole record.
615///
616/// This mirror reproduces the EXACT pre-#1538 variant order and shapes — it is
617/// the current [`CellOperation`] with ONLY `WriteWithTtl` reverted to 3 fields.
618/// Crucially the `Delete` variant keeps its post-#921 `{ column,
619/// local_deletion_time }` shape (unlike the older [`LegacyCellOperation`], whose
620/// pre-#921 `Delete { column }` would misdecode a real mixed
621/// `WriteWithTtl` + `Delete` record — e.g. `UPDATE … USING TTL SET a=1, b=null`),
622/// so every op of a pre-#1538 record round-trips faithfully. It MUST stay in
623/// lockstep with [`CellOperation`]: variant order is the bincode discriminant.
624#[derive(serde::Serialize, serde::Deserialize)]
625enum PreCellLdtWriteTtlCellOperation {
626 Write {
627 column: String,
628 value: crate::types::Value,
629 },
630 /// Pre-#1538 expiring cell: no per-cell `local_deletion_time` field.
631 WriteWithTtl {
632 column: String,
633 value: crate::types::Value,
634 ttl_seconds: u32,
635 },
636 /// Post-#921 `Delete` carries its per-cell `local_deletion_time` (current shape).
637 Delete {
638 column: String,
639 local_deletion_time: Option<i32>,
640 },
641 DeleteRow,
642 WriteComplexElement {
643 column: String,
644 cell_path: Vec<u8>,
645 value: Option<crate::types::Value>,
646 timestamp_micros: i64,
647 ttl_seconds: Option<u32>,
648 local_deletion_time: Option<i32>,
649 is_deleted: bool,
650 },
651 ComplexDeletion {
652 column: String,
653 marked_for_delete_at: i64,
654 local_deletion_time: i32,
655 },
656}
657
658impl From<PreCellLdtWriteTtlCellOperation> for CellOperation {
659 fn from(op: PreCellLdtWriteTtlCellOperation) -> Self {
660 match op {
661 PreCellLdtWriteTtlCellOperation::Write { column, value } => {
662 CellOperation::Write { column, value }
663 }
664 // Pre-#1538 expiring cell: no surfaced source LDT, so the writer
665 // derives `now + ttl` (historical behavior).
666 PreCellLdtWriteTtlCellOperation::WriteWithTtl {
667 column,
668 value,
669 ttl_seconds,
670 } => CellOperation::WriteWithTtl {
671 column,
672 value,
673 ttl_seconds,
674 local_deletion_time: None,
675 },
676 PreCellLdtWriteTtlCellOperation::Delete {
677 column,
678 local_deletion_time,
679 } => CellOperation::Delete {
680 column,
681 local_deletion_time,
682 },
683 PreCellLdtWriteTtlCellOperation::DeleteRow => CellOperation::DeleteRow,
684 PreCellLdtWriteTtlCellOperation::WriteComplexElement {
685 column,
686 cell_path,
687 value,
688 timestamp_micros,
689 ttl_seconds,
690 local_deletion_time,
691 is_deleted,
692 } => CellOperation::WriteComplexElement {
693 column,
694 cell_path,
695 value,
696 timestamp_micros,
697 ttl_seconds,
698 local_deletion_time,
699 is_deleted,
700 },
701 PreCellLdtWriteTtlCellOperation::ComplexDeletion {
702 column,
703 marked_for_delete_at,
704 local_deletion_time,
705 } => CellOperation::ComplexDeletion {
706 column,
707 marked_for_delete_at,
708 local_deletion_time,
709 },
710 }
711 }
712}
713
714/// On-disk `Mutation` layout post-Issue #1018, pre-Issue #1538 (layout (E)).
715///
716/// Issue #1538 appended `local_deletion_time` to
717/// [`CellOperation::WriteWithTtl`] (inside `operations: Vec<CellOperation>`). A
718/// record written by the immediately-preceding (post-#1018, pre-#1538) binary
719/// carries ALL current mutation-level trailing fields (`local_deletion_time`,
720/// `row_tombstone`, `cell_write_timestamps`) but encodes any `WriteWithTtl` op
721/// with the pre-#1538 3-field shape. Decoding it as the current [`Mutation`]
722/// (whose `WriteWithTtl` now expects a 4th field) misaligns: bincode either
723/// errors, or worse succeeds-but-wrong by reading the tag byte of the following
724/// mutation field as the missing `Option` — silent corruption. This mirror is
725/// identical to the current [`Mutation`] field-for-field, with only `operations`
726/// swapped to [`PreCellLdtWriteTtlCellOperation`], so such records replay
727/// faithfully with `WriteWithTtl.local_deletion_time = None`.
728///
729/// The field order MUST stay in lockstep with [`Mutation`].
730#[derive(serde::Serialize, serde::Deserialize)]
731struct PreCellLdtWriteTtlMutation {
732 table: TableId,
733 partition_key: PartitionKey,
734 clustering_key: Option<ClusteringKey>,
735 operations: Vec<PreCellLdtWriteTtlCellOperation>,
736 timestamp_micros: i64,
737 ttl_seconds: Option<u32>,
738 partition_tombstone: Option<PartitionTombstone>,
739 range_tombstones: Vec<RangeTombstone>,
740 local_deletion_time: Option<i32>,
741 row_tombstone: Option<(i64, i32)>,
742 cell_write_timestamps: Option<std::collections::HashMap<String, i64>>,
743}
744
745impl From<PreCellLdtWriteTtlMutation> for Mutation {
746 fn from(m: PreCellLdtWriteTtlMutation) -> Self {
747 Mutation {
748 table: m.table,
749 partition_key: m.partition_key,
750 clustering_key: m.clustering_key,
751 operations: m.operations.into_iter().map(CellOperation::from).collect(),
752 timestamp_micros: m.timestamp_micros,
753 ttl_seconds: m.ttl_seconds,
754 partition_tombstone: m.partition_tombstone,
755 range_tombstones: m.range_tombstones,
756 local_deletion_time: m.local_deletion_time,
757 row_tombstone: m.row_tombstone,
758 cell_write_timestamps: m.cell_write_timestamps,
759 }
760 }
761}
762
763/// Deserialize a `Mutation` from WAL bytes, tolerating records written by an
764/// older binary that predates the `Mutation::local_deletion_time` field
765/// (Issue #764), the `CellOperation::Delete::local_deletion_time` field
766/// (Issue #921), the `Mutation::row_tombstone` (Issue #932) /
767/// `cell_write_timestamps` (Issue #1018) fields, or the
768/// `CellOperation::WriteWithTtl::local_deletion_time` field (Issue #1538).
769///
770/// Attempts the layouts most-recent-first (each subsequent layout has fewer
771/// trailing fields / an older op shape): current, then layout (E) (post-#1018/
772/// pre-#1538: all current mutation fields but a 3-field `WriteWithTtl`), then
773/// (D), (C), (B), (A). Each maps to the current [`Mutation`]/[`CellOperation`]
774/// with the newer fields upgraded to their historical defaults (`None`).
775fn decode_mutation(bytes: &[u8]) -> std::result::Result<Mutation, bincode::Error> {
776 // RESIDUAL RISK (inherent to the versionless, positional bincode WAL): because
777 // the current-layout decode is tried FIRST and we return on its first `Ok(..)`,
778 // correctness for a pre-version record relies on that current-layout decode
779 // ERRORING rather than succeeding-but-wrong. #1538 widened `WriteWithTtl` 3->4
780 // fields, so a pre-#1538 record's trailing byte can be positionally misread as
781 // the new `Option<i32>` tag; there is no GENERAL guarantee every such record's
782 // trailing bytes form an invalid tag (the mirror tests only prove the realistic
783 // cases, e.g. WriteWithTtl followed by Delete). This is NOT a new bug — the same
784 // succeed-but-wrong hazard already applies to layouts A-D — just a new instance.
785 // The structural mitigation (a per-record version tag / length-prefixed op
786 // envelope) is tracked in issue #2054; do NOT attempt it here.
787 match bincode::deserialize::<Mutation>(bytes) {
788 Ok(mutation) => Ok(mutation),
789 Err(current_err) => {
790 // Layout (E): post-#1018, pre-#1538 — ALL current mutation-level
791 // trailing fields present, but `WriteWithTtl` ops carry the pre-#1538
792 // 3-field shape. Tried FIRST among the fallbacks because it has the
793 // most trailing fields (same count as the current layout), so a
794 // pre-#1538 `WriteWithTtl` record decodes here rather than misaligning
795 // under the fewer-trailing-field mirrors below. A current record never
796 // reaches this block (the current layout above already decoded it), and
797 // an older (D/C/B/A) record lacks bytes for the extra trailing field(s)
798 // and errors out of this mirror into the correct one below.
799 if let Ok(m) = bincode::deserialize::<PreCellLdtWriteTtlMutation>(bytes) {
800 return Ok(Mutation::from(m));
801 }
802 // Layout (D): post-#932, pre-#1018 — current op shapes + mutation LDT
803 // + `row_tombstone` but no trailing `cell_write_timestamps`. Tried
804 // first so a #932-era record decodes correctly rather than misaligning
805 // under the older mirrors below.
806 if let Ok(m) = bincode::deserialize::<PreCellWriteTimestampsMutation>(bytes) {
807 return Ok(Mutation::from(m));
808 }
809 // Layout (C): post-#921, pre-#932 — current op shapes + mutation LDT
810 // but no trailing `row_tombstone`. Tried first so a #921-era record
811 // with current `Delete { column, local_deletion_time }` ops decodes
812 // correctly rather than misaligning under the pre-#921 mirrors below.
813 if let Ok(m) = bincode::deserialize::<PreRowTombstoneMutation>(bytes) {
814 return Ok(Mutation::from(m));
815 }
816 // Layout (B): mutation-level LDT present, pre-#921 Delete ops.
817 if let Ok(m) = bincode::deserialize::<LegacyMutationWithLdt>(bytes) {
818 return Ok(Mutation::from(m));
819 }
820 // Layout (A): pre-#764 (no mutation LDT), pre-#921 Delete ops. If
821 // this also fails, surface the original (current-layout) error,
822 // which is the more informative one.
823 bincode::deserialize::<LegacyMutation>(bytes)
824 .map(Mutation::from)
825 .map_err(|_| current_err)
826 }
827 }
828}
829
830/// Write-ahead log for crash recovery
831///
832/// Provides durable storage for mutations before they reach the memtable.
833/// Every mutation is serialized to an append-only log and fsync'd to disk.
834///
835/// ## Usage
836///
837/// ```no_run
838/// use cqlite_core::storage::write_engine::{WriteAheadLog, Mutation};
839/// use std::path::Path;
840///
841/// # fn example() -> cqlite_core::error::Result<()> {
842/// // Create a new WAL
843/// let mut wal = WriteAheadLog::create(Path::new("/data"))?;
844///
845/// // Append mutations (serialized with CRC32)
846/// // let mutation = Mutation::new(...);
847/// // wal.append(&mutation)?;
848///
849/// // Explicit sync to disk
850/// wal.sync()?;
851///
852/// // On recovery, replay all valid entries
853/// // let mutations = wal.replay()?;
854/// # Ok(())
855/// # }
856/// ```
857pub struct WriteAheadLog {
858 /// Buffered writer for sequential appends
859 file: BufWriter<File>,
860 /// Path to the WAL file
861 path: PathBuf,
862 /// Buffer size (4KB default) - stored for diagnostic purposes
863 #[allow(dead_code)]
864 buffer_size: usize,
865 /// Current size of the WAL file (in bytes)
866 current_size: u64,
867 /// Reusable scratch buffer for serializing a mutation in `append`, so the
868 /// steady-state write path does not allocate a fresh `Vec` per call. Cleared
869 /// and refilled on every `append`; never shared across threads (`&mut self`).
870 append_scratch: Vec<u8>,
871 /// Byte offset of the last CRC-valid prefix when `open_existing` detected
872 /// mid-stream corruption (issue #1391). The corrupt tail is intentionally
873 /// left on disk so the caller can preserve it aside as forensic evidence
874 /// FIRST; once that is done the caller invokes
875 /// [`reset_to_valid_prefix`](Self::reset_to_valid_prefix) so post-recovery
876 /// appends land at a replayable position instead of after the corrupt bytes
877 /// (where a synced write would be lost on the next replay). `None` for a
878 /// freshly created WAL, a clean reopen, or a torn tail (already trimmed).
879 pending_valid_prefix: Option<u64>,
880 /// Set when a post-`set_len(0)` restore step (issue #1392, FINDING 1) left
881 /// the WAL in a state where the file cursor's position no longer
882 /// authoritatively matches `current_size`. The canonical case is a failed
883 /// `seek(0)` after truncation: the cursor may remain at the stale OLD
884 /// end-of-file while `current_size` has been reset to 0, so a later
885 /// `append()` would write at that stale offset over the now-zeroed file,
886 /// producing a sparse / misparsed WAL (silent corruption). Once poisoned,
887 /// every `append`/`sync`/`truncate` fails closed with a typed
888 /// [`Error::Storage`] until the WAL is reopened.
889 poisoned: Option<String>,
890 /// Test-only fault injection: when set, force the `sync_all` performed
891 /// *after* `set_len(0)` inside [`WriteAheadLog::truncate_checked`] to fail,
892 /// exercising the `AfterMutation` state-restore path (issue #1392).
893 #[cfg(test)]
894 fail_sync_after_truncate: bool,
895 /// Test-only fault injection: when set, force the `seek(0)` performed
896 /// *after* `set_len(0)` inside [`WriteAheadLog::truncate_checked`] to fail,
897 /// exercising the poison-on-partial-failure path (issue #1392, FINDING 1).
898 #[cfg(test)]
899 fail_seek_after_truncate: bool,
900}
901
902// Manual `Debug` (not derived) so the reusable `append_scratch` buffer — which
903// holds the last serialized mutation's raw row bytes — is never printed under
904// `{:?}`. Leaking those bytes would expose user data and could produce huge log
905// lines; we summarize the buffer by len/capacity only. All other fields keep
906// their usual `Debug` representation.
907impl std::fmt::Debug for WriteAheadLog {
908 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
909 let mut dbg = f.debug_struct("WriteAheadLog");
910 dbg.field("file", &self.file)
911 .field("path", &self.path)
912 .field("buffer_size", &self.buffer_size)
913 .field("current_size", &self.current_size)
914 .field(
915 "append_scratch",
916 &format_args!(
917 "<{} bytes, cap {}>",
918 self.append_scratch.len(),
919 self.append_scratch.capacity()
920 ),
921 )
922 .field("pending_valid_prefix", &self.pending_valid_prefix)
923 .field("poisoned", &self.poisoned);
924 #[cfg(test)]
925 dbg.field("fail_sync_after_truncate", &self.fail_sync_after_truncate)
926 .field("fail_seek_after_truncate", &self.fail_seek_after_truncate);
927 dbg.finish()
928 }
929}
930
931impl WriteAheadLog {
932 /// Default buffer size (4 KB)
933 pub const DEFAULT_BUFFER_SIZE: usize = 4096;
934
935 /// WAL file name
936 pub const WAL_FILENAME: &'static str = "commitlog.wal";
937
938 /// Create a new WAL in the specified directory
939 ///
940 /// This creates a new WAL file with the default buffer size (4 KB).
941 /// If a WAL already exists in the directory, it will be truncated.
942 ///
943 /// # Arguments
944 ///
945 /// * `dir` - Directory where the WAL file will be created
946 ///
947 /// # Returns
948 ///
949 /// A new `WriteAheadLog` instance ready for appending.
950 ///
951 /// # Errors
952 ///
953 /// Returns an error if the directory doesn't exist or the file cannot be created.
954 pub fn create(dir: &Path) -> Result<Self> {
955 Self::create_with_buffer_size(dir, Self::DEFAULT_BUFFER_SIZE)
956 }
957
958 /// Create a new WAL with a custom buffer size
959 ///
960 /// # Arguments
961 ///
962 /// * `dir` - Directory where the WAL file will be created
963 /// * `buffer_size` - Size of the append buffer in bytes
964 pub fn create_with_buffer_size(dir: &Path, buffer_size: usize) -> Result<Self> {
965 // Validate directory path for security
966 let validated_dir = validate_wal_directory(dir)?;
967 let path = validated_dir.join(Self::WAL_FILENAME);
968
969 let file = OpenOptions::new()
970 .create(true)
971 .write(true)
972 .truncate(true)
973 .open(&path)
974 .map_err(|e| Error::Storage(format!("Failed to create WAL at {:?}: {}", path, e)))?;
975
976 // Set secure file permissions (Unix: 0o600)
977 set_secure_permissions(&file)?;
978
979 // Sync directory to ensure file entry is persisted
980 sync_directory(&validated_dir)?;
981
982 Ok(Self {
983 file: BufWriter::with_capacity(buffer_size, file),
984 path,
985 buffer_size,
986 current_size: 0,
987 append_scratch: Vec::new(),
988 pending_valid_prefix: None,
989 poisoned: None,
990 #[cfg(test)]
991 fail_sync_after_truncate: false,
992 #[cfg(test)]
993 fail_seek_after_truncate: false,
994 })
995 }
996
997 /// Open an existing WAL file for appending
998 ///
999 /// This opens an existing WAL and seeks to the end, ready for new appends.
1000 /// Use this for recovery scenarios where you want to append to an existing log.
1001 ///
1002 /// # Arguments
1003 ///
1004 /// * `path` - Path to the existing WAL file
1005 ///
1006 /// # Returns
1007 ///
1008 /// A `WriteAheadLog` positioned at the end of the file.
1009 ///
1010 /// # Errors
1011 ///
1012 /// Returns an error if the file doesn't exist or cannot be opened.
1013 pub fn open_existing(path: &Path) -> Result<Self> {
1014 // Determine the authoritative end-of-log boundary BEFORE opening for
1015 // append. A crash can leave a partial (torn) entry at the tail; if it is
1016 // retained, every subsequent append lands AFTER the garbage and is
1017 // silently unrecoverable on the next replay (issue #1390). The boundary
1018 // is derived from the length-prefixed + CRC32 framing (authoritative,
1019 // not a byte-pattern guess), matching the no-heuristics mandate.
1020 let (valid_end, stop) = Self::scan_valid_prefix(path)?;
1021
1022 let file = OpenOptions::new()
1023 .read(true)
1024 .append(true)
1025 .open(path)
1026 .map_err(|e| Error::Storage(format!("Failed to open WAL at {:?}: {}", path, e)))?;
1027
1028 let metadata = file
1029 .metadata()
1030 .map_err(|e| Error::Storage(format!("Failed to read WAL metadata: {}", e)))?;
1031
1032 let file_len = metadata.len();
1033
1034 // Only a TORN TAIL — now strictly a torn HEADER (< 8 bytes at EOF), the
1035 // sole structurally-incomplete tail with provably no complete entry to
1036 // lose (issue #1391 r5) — may be trimmed HERE; that is #1390's guarantee
1037 // that future appends resume at the last valid boundary. Any COMPLETE-
1038 // header entry that cannot be trusted (`Corruption`: bad CRC, implausible
1039 // length, or a declared length that overshoots EOF) may be a bit-flipped
1040 // record swallowing VALID successors, so silently `set_len`-ing it away at
1041 // open time would discard evidence before it can be preserved. We
1042 // therefore leave a corrupt log
1043 // physically intact so `replay()` surfaces the loss and `WriteEngine`
1044 // can copy the raw segment aside; the caller then invokes
1045 // `reset_to_valid_prefix` (issue #1391) to trim the LIVE log back to the
1046 // valid prefix BEFORE accepting writes, so a post-recovery synced append
1047 // lands at a replayable position rather than after the corrupt bytes.
1048 let mut pending_valid_prefix = None;
1049 let current_size = if stop == WalStop::TornTail && valid_end < file_len {
1050 file.set_len(valid_end).map_err(|e| {
1051 Error::Storage(format!("Failed to trim torn WAL tail at {:?}: {}", path, e))
1052 })?;
1053 file.sync_all().map_err(|e| {
1054 Error::Storage(format!(
1055 "Failed to sync WAL after trim at {:?}: {}",
1056 path, e
1057 ))
1058 })?;
1059 if let Some(parent) = path.parent() {
1060 sync_directory(parent)?;
1061 }
1062 tracing::warn!(
1063 "WAL {:?} had a torn tail: trimmed {} byte(s) ({} -> {})",
1064 path,
1065 file_len - valid_end,
1066 file_len,
1067 valid_end
1068 );
1069 valid_end
1070 } else {
1071 if stop == WalStop::Corruption && valid_end < file_len {
1072 // Do not paper over corruption HERE: leave the corrupt segment on
1073 // disk so the caller can preserve it aside first, and record the
1074 // valid-prefix boundary so `reset_to_valid_prefix` can trim the
1075 // live log once the evidence is safe.
1076 tracing::error!(
1077 "WAL {:?} has corruption at offset {} ({} valid prefix byte(s) of {}); \
1078 leaving the segment intact for evidence preservation before reset",
1079 path,
1080 valid_end,
1081 valid_end,
1082 file_len
1083 );
1084 pending_valid_prefix = Some(valid_end);
1085 }
1086 file_len
1087 };
1088
1089 Ok(Self {
1090 file: BufWriter::with_capacity(Self::DEFAULT_BUFFER_SIZE, file),
1091 path: path.to_path_buf(),
1092 buffer_size: Self::DEFAULT_BUFFER_SIZE,
1093 current_size,
1094 append_scratch: Vec::new(),
1095 pending_valid_prefix,
1096 poisoned: None,
1097 #[cfg(test)]
1098 fail_sync_after_truncate: false,
1099 #[cfg(test)]
1100 fail_seek_after_truncate: false,
1101 })
1102 }
1103
1104 /// True if `open_existing` found mid-stream corruption whose valid prefix has
1105 /// not yet been reset (issue #1391). While this holds, the LIVE WAL still
1106 /// carries the corrupt tail and appends would land after it (lost on the next
1107 /// replay) — the caller must preserve the segment aside and then call
1108 /// [`reset_to_valid_prefix`](Self::reset_to_valid_prefix) before any write.
1109 pub fn has_pending_corrupt_tail(&self) -> bool {
1110 self.pending_valid_prefix.is_some()
1111 }
1112
1113 /// After a lossy (`Corruption`) recovery, trim the LIVE WAL back to its last
1114 /// CRC-valid prefix so post-recovery appends resume at a replayable position
1115 /// (issue #1391). This is a no-op unless `open_existing` recorded a pending
1116 /// corrupt tail; the caller MUST first preserve the raw segment aside (the
1117 /// trim is destructive to the on-disk corrupt bytes, though the aside copy
1118 /// and the retained `RecoveryReport` survive).
1119 ///
1120 /// Returns the new (valid-prefix) length when a trim occurred, or `None` when
1121 /// there was nothing pending.
1122 ///
1123 /// # Errors
1124 ///
1125 /// Returns an error if the buffer flush, `set_len`, or fsync fails.
1126 pub fn reset_to_valid_prefix(&mut self) -> Result<Option<u64>> {
1127 // Peek the pending value WITHOUT clearing it (issue #1391, roborev r3): if
1128 // any step below (flush / set_len / fsync / directory sync) fails, the
1129 // corrupt tail is still (partly) on disk and the guard MUST stay set so
1130 // `append()` remains fail-closed. Clearing here — as `.take()` did — left
1131 // the WAL appendable after the corrupt tail again on any mid-reset error,
1132 // reintroducing the acknowledged-write-loss window. The guard is cleared
1133 // only after ALL steps succeed (see the end of this function).
1134 let valid_end = match self.pending_valid_prefix.as_ref() {
1135 Some(end) => *end,
1136 None => return Ok(None),
1137 };
1138
1139 // Flush any buffered bytes first (there should be none — nothing has been
1140 // appended since open — but do not rely on that).
1141 self.file
1142 .flush()
1143 .map_err(|e| Error::Storage(format!("Failed to flush WAL before reset: {}", e)))?;
1144
1145 let file_len = self
1146 .file
1147 .get_ref()
1148 .metadata()
1149 .map_err(|e| Error::Storage(format!("Failed to read WAL metadata for reset: {}", e)))?
1150 .len();
1151
1152 if valid_end >= file_len {
1153 // Nothing beyond the valid prefix to trim (e.g. already reset). This is
1154 // a benign success, so it is safe to lift the guard.
1155 self.current_size = file_len;
1156 self.pending_valid_prefix = None;
1157 return Ok(None);
1158 }
1159
1160 self.file.get_mut().set_len(valid_end).map_err(|e| {
1161 Error::Storage(format!(
1162 "Failed to reset WAL to valid prefix at {:?}: {}",
1163 self.path, e
1164 ))
1165 })?;
1166 self.file.get_ref().sync_all().map_err(|e| {
1167 Error::Storage(format!(
1168 "Failed to sync WAL after reset at {:?}: {}",
1169 self.path, e
1170 ))
1171 })?;
1172 if let Some(parent) = self.path.parent() {
1173 sync_directory(parent)?;
1174 }
1175
1176 tracing::warn!(
1177 "WAL {:?} reset to last valid prefix after lossy recovery: {} -> {} \
1178 ({} corrupt byte(s) dropped from the LIVE log; evidence preserved aside)",
1179 self.path,
1180 file_len,
1181 valid_end,
1182 file_len - valid_end
1183 );
1184
1185 self.current_size = valid_end;
1186 // All steps succeeded — only now is it safe to lift the fail-closed guard.
1187 self.pending_valid_prefix = None;
1188 Ok(Some(valid_end))
1189 }
1190
1191 /// Scan the WAL and return the byte offset at the end of the last CRC-valid
1192 /// entry — the boundary at which future appends must resume.
1193 ///
1194 /// Reads entries sequentially using the same length-prefixed + CRC32 framing
1195 /// as [`replay`](Self::replay). Scanning stops (the valid prefix ends) at the
1196 /// first entry that is:
1197 /// - missing/short in its 8-byte header ([`WalStop::TornTail`] — a torn
1198 /// header is the only structurally-incomplete tail that may be trimmed as
1199 /// clean),
1200 /// - declaring an implausible length (> [`MAX_ENTRY_LENGTH`], garbage tail —
1201 /// [`WalStop::Corruption`]),
1202 /// - complete in its header but short in its payload at EOF
1203 /// ([`WalStop::Corruption`] — a complete-header entry whose declared length
1204 /// cannot be satisfied is indistinguishable from a length bit-flip that
1205 /// overshoots EOF, so it is preserved, not trimmed; issue #1391 r5), or
1206 /// - CRC-invalid ([`WalStop::Corruption`] — corrupt framing, offsets past it
1207 /// cannot be trusted).
1208 ///
1209 /// The returned offset is therefore the end of a contiguous run of fully
1210 /// written, CRC-valid entries. Bytes beyond it are either a torn HEADER left
1211 /// by an interrupted append (trimmed as clean) or a corrupt/unsatisfiable
1212 /// complete-header entry (preserved for evidence, reset separately).
1213 fn scan_valid_prefix(path: &Path) -> Result<(u64, WalStop)> {
1214 let mut file = File::open(path)
1215 .map_err(|e| Error::Storage(format!("Failed to open WAL for scan: {}", e)))?;
1216
1217 let mut offset = 0u64;
1218
1219 loop {
1220 // Read entry header: [length][crc32].
1221 let mut header = [0u8; 8];
1222 match file.read_exact(&mut header) {
1223 Ok(_) => {}
1224 // Clean boundary vs a header cut short: a full-length read that
1225 // hit EOF exactly on the entry boundary (`file len == offset`) is
1226 // a clean end-of-log; any surplus bytes are a torn header. This
1227 // holds at `offset == 0` too: a WAL that is JUST a 1-7 byte torn
1228 // first header (nothing valid before it) is a torn tail to be
1229 // trimmed, NOT a clean EOF — an `offset == 0` short-circuit here
1230 // would leave those garbage bytes in place and every later append
1231 // would land after them, unrecoverable on the next replay
1232 // (issue #1391).
1233 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
1234 let stop = if Self::at_clean_eof(&mut file, offset)? {
1235 WalStop::CleanEof
1236 } else {
1237 WalStop::TornTail
1238 };
1239 return Ok((offset, stop));
1240 }
1241 Err(e) => {
1242 return Err(Error::Storage(format!(
1243 "Failed to scan WAL header at offset {}: {}",
1244 offset, e
1245 )));
1246 }
1247 }
1248
1249 let entry_length = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
1250 let expected_crc = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
1251
1252 // Implausible length on a fully-present header => corruption, not a
1253 // torn tail: the framing is untrustworthy and any successors must be
1254 // preserved rather than discarded.
1255 if entry_length > MAX_ENTRY_LENGTH {
1256 return Ok((offset, WalStop::Corruption));
1257 }
1258
1259 // Read the declared payload. A short read (EOF before `entry_length`
1260 // bytes) on a COMPLETE 8-byte header is CORRUPTION, not a torn tail
1261 // (issue #1391, roborev r5). Using only the allowed no-heuristics
1262 // facts (header completeness, declared-length vs remaining-bytes,
1263 // physical-tail position) an interrupted final write of THIS entry is
1264 // indistinguishable from a bit-flipped length on a fully-written entry
1265 // whose declared size overshoots EOF and swallows valid successors —
1266 // both are "complete header, declared length > remaining bytes, at the
1267 // physical tail". Guessing which would be a byte-pattern heuristic. So
1268 // we treat it as corruption: leave the tail intact for evidence and let
1269 // the caller preserve + report it, rather than silently trimming an
1270 // acknowledged successor away as a clean torn tail. Only a torn HEADER
1271 // (< 8 bytes, handled above) is a structurally-incomplete tail that may
1272 // be trimmed-as-clean.
1273 let mut payload = vec![0u8; entry_length as usize];
1274 match file.read_exact(&mut payload) {
1275 Ok(_) => {}
1276 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
1277 return Ok((offset, WalStop::Corruption));
1278 }
1279 Err(e) => {
1280 return Err(Error::Storage(format!(
1281 "Failed to scan WAL payload at offset {}: {}",
1282 offset, e
1283 )));
1284 }
1285 }
1286
1287 // Verify CRC. A mismatch on a fully-present entry means the framing
1288 // (including the length we just trusted) is unreliable, so this is
1289 // corruption — offsets past it cannot be resynced authoritatively.
1290 let mut hasher = Hasher::new();
1291 hasher.update(&payload);
1292 if hasher.finalize() != expected_crc {
1293 return Ok((offset, WalStop::Corruption));
1294 }
1295
1296 offset += 8 + entry_length as u64;
1297 }
1298 }
1299
1300 /// Distinguish a clean end-of-log from a torn header after a short header
1301 /// read: seek to `offset` and check that exactly zero bytes remain.
1302 fn at_clean_eof(file: &mut File, offset: u64) -> Result<bool> {
1303 let len = file
1304 .metadata()
1305 .map_err(|e| Error::Storage(format!("Failed to read WAL metadata during scan: {}", e)))?
1306 .len();
1307 Ok(len == offset)
1308 }
1309
1310 /// Append a mutation to the WAL
1311 ///
1312 /// This serializes the mutation using bincode and writes it to the buffer.
1313 /// The entry is not guaranteed to be on disk until `sync()` is called.
1314 ///
1315 /// # Entry Format
1316 ///
1317 /// ```text
1318 /// [u32 LE: entry_length]
1319 /// [u32 LE: crc32]
1320 /// [bytes: serialized mutation]
1321 /// ```
1322 ///
1323 /// # Arguments
1324 ///
1325 /// * `mutation` - The mutation to append
1326 ///
1327 /// # Errors
1328 ///
1329 /// Returns an error if serialization fails or the write fails.
1330 #[tracing::instrument(name = "wal.append", level = "debug", skip(self, mutation))]
1331 pub fn append(&mut self, mutation: &Mutation) -> Result<()> {
1332 // Fail closed if a prior truncate poisoned the WAL (issue #1392,
1333 // FINDING 1): appending at a stale cursor over a zeroed file would
1334 // produce a sparse / misparsed WAL.
1335 self.ensure_not_poisoned()?;
1336
1337 // Refuse to append while a mid-stream corrupt tail is still on disk
1338 // (issue #1391). `open_existing` deliberately leaves the corrupt segment
1339 // intact for evidence preservation and records the valid-prefix boundary
1340 // in `pending_valid_prefix`. If a direct public-API consumer appended now
1341 // (without the `WriteEngine::new` reset), the new entry would land AFTER
1342 // the corrupt tail — exactly where the next `replay()` stops — so the
1343 // acknowledged write would be silently lost. The caller must preserve the
1344 // segment aside and call `reset_to_valid_prefix()` first (which clears
1345 // this flag). `WriteEngine::new` already does this before any write.
1346 if self.pending_valid_prefix.is_some() {
1347 return Err(Error::Storage(
1348 "WAL has an unreset corrupt tail; reset_to_valid_prefix required before appending"
1349 .to_string(),
1350 ));
1351 }
1352
1353 // Serialize mutation using bincode into a reusable scratch buffer so the
1354 // steady-state write path does not allocate a fresh `Vec` per call.
1355 // `serialize_into` yields byte-identical output to `bincode::serialize`.
1356 self.append_scratch.clear();
1357 bincode::serialize_into(&mut self.append_scratch, mutation)
1358 .map_err(|e| Error::Storage(format!("Failed to serialize mutation: {}", e)))?;
1359
1360 // Fail-closed size ceiling (issue #1391, roborev r3). `replay()` /
1361 // `scan_valid_prefix` classify any entry whose declared length exceeds
1362 // `MAX_ENTRY_LENGTH` as corruption and STOP. If `append()` accepted a
1363 // larger entry, a >16 MiB write could be fsync-acknowledged here and then
1364 // silently dropped as "corrupt" on the next recovery — acknowledged-write
1365 // loss. The write path's accepted max MUST equal the replay/scan limit, so
1366 // reject BEFORE writing anything. The comparison is done in `u64` so a
1367 // multi-GiB length cannot truncate through the `as u32` cast below into a
1368 // small, wrongly-accepted value.
1369 if self.append_scratch.len() as u64 > MAX_ENTRY_LENGTH as u64 {
1370 let serialized_len = self.append_scratch.len();
1371 // A single oversized (rejected) mutation must not permanently bloat the
1372 // WAL instance's retained scratch capacity: release it before returning.
1373 self.append_scratch.clear();
1374 self.append_scratch.shrink_to_fit();
1375 return Err(Error::Storage(format!(
1376 "WAL entry exceeds MAX_ENTRY_LENGTH (16 MiB): {} bytes",
1377 serialized_len
1378 )));
1379 }
1380
1381 let entry_length = self.append_scratch.len() as u32;
1382
1383 // Calculate CRC32 over the mutation bytes
1384 let mut hasher = Hasher::new();
1385 hasher.update(&self.append_scratch);
1386 let crc32 = hasher.finalize();
1387
1388 // Write entry: [length][crc32][mutation_bytes]
1389 self.file
1390 .write_all(&entry_length.to_le_bytes())
1391 .map_err(|e| Error::Storage(format!("Failed to write entry length: {}", e)))?;
1392
1393 self.file
1394 .write_all(&crc32.to_le_bytes())
1395 .map_err(|e| Error::Storage(format!("Failed to write CRC32: {}", e)))?;
1396
1397 self.file
1398 .write_all(&self.append_scratch)
1399 .map_err(|e| Error::Storage(format!("Failed to write mutation bytes: {}", e)))?;
1400
1401 // Update size (8 bytes header + mutation bytes)
1402 self.current_size += 8 + entry_length as u64;
1403
1404 Ok(())
1405 }
1406
1407 /// Sync the WAL to disk (fsync)
1408 ///
1409 /// This flushes the buffer and calls fsync to ensure all data is written
1410 /// to persistent storage. This is required for durability guarantees.
1411 ///
1412 /// # Errors
1413 ///
1414 /// Returns an error if the flush or sync operation fails.
1415 #[tracing::instrument(name = "wal.sync", level = "debug", skip(self))]
1416 pub fn sync(&mut self) -> Result<()> {
1417 // Fail closed if the WAL was poisoned by a partial truncate-restore
1418 // (issue #1392, FINDING 1): its buffered/cursor state is not trustworthy.
1419 self.ensure_not_poisoned()?;
1420
1421 self.file
1422 .flush()
1423 .map_err(|e| Error::Storage(format!("Failed to flush WAL buffer: {}", e)))?;
1424
1425 // Record fsync latency in seconds (issue #1036). The histogram captures
1426 // only the durable-write step, which dominates WAL sync cost.
1427 let fsync_start = std::time::Instant::now();
1428 self.file
1429 .get_ref()
1430 .sync_all()
1431 .map_err(|e| Error::Storage(format!("Failed to sync WAL to disk: {}", e)))?;
1432 crate::observability::record_histogram(
1433 crate::observability::catalog::WAL_SYNC_DURATION,
1434 fsync_start.elapsed().as_secs_f64(),
1435 &[],
1436 );
1437
1438 Ok(())
1439 }
1440
1441 /// Replay all valid entries from the WAL
1442 ///
1443 /// Reads the WAL from the beginning and deserializes all valid entries.
1444 /// This is used during crash recovery to rebuild the memtable.
1445 ///
1446 /// ## Corruption Handling (issue #1391)
1447 ///
1448 /// Recovery is fail-fast-then-report — see [`RecoveryReport`] for the full
1449 /// rationale. In summary:
1450 ///
1451 /// - **CRC mismatch / implausible length** on a fully-present entry: the
1452 /// framing past this point cannot be resynced authoritatively (no sync
1453 /// markers), so replay STOPS ([`RecoveryReport::stopped_early`]) and
1454 /// records the loss. It does NOT advance by the untrusted length (that was
1455 /// the misalignment bug that decoded garbage).
1456 /// - **CRC-valid but undecodable payload**: the entry boundary is
1457 /// trustworthy, so this single entry is skipped
1458 /// ([`RecoveryReport::corrupt_entries`]) and replay continues.
1459 /// - **Complete header + short payload at EOF**: a complete-header entry
1460 /// whose declared length cannot be satisfied is CORRUPTION (issue #1391
1461 /// r5), not a clean torn tail — it is indistinguishable from a length
1462 /// bit-flip that overshoots EOF and swallows valid successors. Replay stops
1463 /// early and records the loss ([`RecoveryReport::stopped_early`]).
1464 /// - **Torn header** (short 8-byte header at EOF): an interrupted final
1465 /// append that never finished its header — there is provably no complete
1466 /// entry to lose, so replay stops cleanly (not counted as corruption).
1467 /// - **Valid entries**: deserialized and returned in order.
1468 ///
1469 /// # Returns
1470 ///
1471 /// A [`RecoveryReport`]. Callers MUST check [`RecoveryReport::is_clean`]
1472 /// before treating recovery as complete; a non-clean report signals data
1473 /// loss that must be surfaced, not silently truncated over.
1474 ///
1475 /// # Errors
1476 ///
1477 /// Returns an error if the WAL file cannot be opened or read (an I/O fault
1478 /// distinct from in-band corruption, which is reported, not errored).
1479 pub fn replay(&self) -> Result<RecoveryReport> {
1480 // Thin wrapper over `replay_each` (issue #1661): collect the streamed
1481 // mutations into the report's Vec so existing callers/tests are
1482 // unchanged. `replay_each` itself never materialises the whole log.
1483 let mut out = Vec::new();
1484 let mut report = self.replay_each(|m| {
1485 out.push(m);
1486 Ok(())
1487 })?;
1488 report.mutations = out;
1489 Ok(report)
1490 }
1491
1492 /// Streaming form of [`replay`](Self::replay): invoke `f` once per recovered
1493 /// mutation instead of accumulating a whole-log `Vec<Mutation>` (issue #1661).
1494 ///
1495 /// Control flow — decode/skip/stop/error semantics and the on-disk framing —
1496 /// is byte-for-byte identical to [`replay`](Self::replay); this is a pure
1497 /// memory refactor. The two differences are internal:
1498 ///
1499 /// 1. A single payload buffer is reused across entries (its capacity grows to
1500 /// the largest entry seen and is retained), so the peak read buffer is
1501 /// bounded by the largest single entry rather than allocated fresh per
1502 /// entry.
1503 /// 2. Each successfully decoded mutation is passed to `f` rather than pushed
1504 /// onto a `Vec`, so the caller (e.g. crash recovery) can insert it into the
1505 /// memtable immediately and never hold the whole log resident.
1506 ///
1507 /// The returned [`RecoveryReport`] carries the same corruption metadata
1508 /// ([`corrupt_entries`](RecoveryReport::corrupt_entries),
1509 /// [`stopped_early`](RecoveryReport::stopped_early),
1510 /// [`bytes_skipped`](RecoveryReport::bytes_skipped)) as `replay`, but its
1511 /// [`mutations`](RecoveryReport::mutations) vec is left empty — the mutations
1512 /// were streamed to `f`. Callers MUST still consult
1513 /// [`RecoveryReport::is_clean`].
1514 ///
1515 /// If `f` returns an error, replay stops and the error propagates unchanged.
1516 ///
1517 /// # Errors
1518 ///
1519 /// Returns an error if the WAL file cannot be opened or read (an I/O fault
1520 /// distinct from in-band corruption, which is reported, not errored), or if
1521 /// `f` returns an error for a recovered mutation.
1522 pub fn replay_each<F>(&self, mut f: F) -> Result<RecoveryReport>
1523 where
1524 F: FnMut(Mutation) -> Result<()>,
1525 {
1526 let mut file = File::open(&self.path)
1527 .map_err(|e| Error::Storage(format!("Failed to open WAL for replay: {}", e)))?;
1528 let total_len = file
1529 .metadata()
1530 .map_err(|e| Error::Storage(format!("Failed to read WAL metadata for replay: {}", e)))?
1531 .len();
1532
1533 let mut report = RecoveryReport::default();
1534 let mut offset = 0u64;
1535 // Reuse ONE payload buffer across entries (issue #1661): `resize` retains
1536 // the allocation's capacity, so the peak read buffer is bounded by the
1537 // largest single entry rather than a fresh `vec![0u8; len]` per entry.
1538 let mut mutation_bytes: Vec<u8> = Vec::new();
1539
1540 loop {
1541 // Read entry header: [length][crc32]
1542 let mut header = [0u8; 8];
1543 match file.read_exact(&mut header) {
1544 Ok(_) => {}
1545 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
1546 // Clean EOF or a torn (never-acknowledged) final append -
1547 // stop replay without flagging corruption.
1548 break;
1549 }
1550 Err(e) => {
1551 return Err(Error::Storage(format!(
1552 "Failed to read WAL header at offset {}: {}",
1553 offset, e
1554 )));
1555 }
1556 }
1557
1558 let entry_length = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
1559 let expected_crc = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
1560
1561 // Implausible length: cannot trust the framing to find the next
1562 // entry, so fail fast and report rather than skipping by a bogus
1563 // length into arbitrary bytes.
1564 if entry_length > MAX_ENTRY_LENGTH {
1565 tracing::error!(
1566 "WAL entry at offset {} declares implausible length {} (> {}) - stopping \
1567 replay; {} trailing byte(s) not recovered",
1568 offset,
1569 entry_length,
1570 MAX_ENTRY_LENGTH,
1571 total_len.saturating_sub(offset)
1572 );
1573 report.corrupt_entries += 1;
1574 report.stopped_early = true;
1575 break;
1576 }
1577
1578 // Read mutation bytes into the reused buffer (issue #1661). `resize`
1579 // grows/shrinks the length to exactly `entry_length` (reusing the
1580 // retained capacity); `read_exact` then overwrites all of it.
1581 mutation_bytes.resize(entry_length as usize, 0);
1582 match file.read_exact(&mut mutation_bytes) {
1583 Ok(_) => {}
1584 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
1585 // Short payload on a COMPLETE 8-byte header is CORRUPTION, not
1586 // a clean torn tail (issue #1391, roborev r5). A bit-flipped
1587 // length that overshoots EOF is structurally indistinguishable
1588 // from an interrupted final write here (both: complete header,
1589 // declared length > remaining bytes, at the physical tail), and
1590 // distinguishing them would be a byte-pattern heuristic. If the
1591 // length was corrupted, the swallowed bytes may hold VALID
1592 // acknowledged successors; breaking silently reported that loss
1593 // as clean. Report it (stop early) so `is_clean()` is false and
1594 // the caller preserves the tail aside. Only a torn HEADER
1595 // (< 8 bytes, the header read above) is a clean torn tail.
1596 tracing::error!(
1597 "WAL entry at offset {} has a complete header declaring length {} but \
1598 only {} payload byte(s) remain to EOF - stopping replay; {} trailing \
1599 byte(s) not recovered (corruption, not a clean torn tail)",
1600 offset,
1601 entry_length,
1602 total_len.saturating_sub(offset + 8),
1603 total_len.saturating_sub(offset)
1604 );
1605 report.corrupt_entries += 1;
1606 report.stopped_early = true;
1607 break;
1608 }
1609 Err(e) => {
1610 return Err(Error::Storage(format!(
1611 "Failed to read WAL entry at offset {}: {}",
1612 offset, e
1613 )));
1614 }
1615 }
1616
1617 // Verify CRC32
1618 let mut hasher = Hasher::new();
1619 hasher.update(&mutation_bytes);
1620 let actual_crc = hasher.finalize();
1621
1622 if actual_crc != expected_crc {
1623 // CRC mismatch on a fully-present entry: the length we just
1624 // trusted is unreliable, so the offset of the next entry is
1625 // unknown. Fail fast and report; do NOT advance-and-continue.
1626 tracing::error!(
1627 "WAL entry at offset {} has CRC mismatch (expected 0x{:08x}, got 0x{:08x}) - \
1628 stopping replay; {} trailing byte(s) not recovered",
1629 offset,
1630 expected_crc,
1631 actual_crc,
1632 total_len.saturating_sub(offset)
1633 );
1634 report.corrupt_entries += 1;
1635 report.stopped_early = true;
1636 break;
1637 }
1638
1639 // CRC valid => the entry boundary is authoritative. Deserialize
1640 // (tolerating legacy pre-#764 records). A decode failure here is a
1641 // format skew our compat layers do not cover; because the boundary
1642 // is trustworthy we skip just this entry and continue.
1643 match decode_mutation(&mutation_bytes) {
1644 Ok(mutation) => {
1645 // Stream the mutation to the caller instead of accumulating a
1646 // whole-log Vec (issue #1661). An `f` error propagates
1647 // unchanged.
1648 f(mutation)?;
1649 }
1650 Err(e) => {
1651 tracing::error!(
1652 "WAL entry at offset {} passed CRC but failed to deserialize: {} - \
1653 skipping this entry and continuing",
1654 offset,
1655 e
1656 );
1657 report.corrupt_entries += 1;
1658 }
1659 }
1660
1661 offset += 8 + entry_length as u64;
1662 }
1663
1664 report.bytes_skipped = total_len.saturating_sub(offset);
1665 Ok(report)
1666 }
1667
1668 /// Truncate the WAL (clear all entries)
1669 ///
1670 /// This is used after a successful flush to memtable/SSTable, removing
1671 /// old entries that are no longer needed for recovery.
1672 ///
1673 /// On success this also lifts any pending corrupt-tail guard (issue #1391):
1674 /// an emptied WAL has no corrupt tail, so subsequent `append()` calls must
1675 /// not be rejected by the stale fail-closed guard.
1676 ///
1677 /// # Errors
1678 ///
1679 /// Returns an error if the truncate operation fails. This flattens the
1680 /// phase distinction of [`WriteAheadLog::truncate_checked`]; the
1681 /// durability handoff (issue #1392) calls `truncate_checked` directly so it
1682 /// can react differently to a failure that occurs *after* the WAL contents
1683 /// have already been zeroed.
1684 pub fn truncate(&mut self) -> Result<()> {
1685 self.truncate_checked().map_err(TruncateError::into_inner)
1686 }
1687
1688 /// Truncate the WAL, distinguishing failures **before** the WAL contents
1689 /// are mutated from failures **after** `set_len(0)` has already zeroed it
1690 /// (issue #1392).
1691 ///
1692 /// The sequence is: flush pending writes, `set_len(0)`, fsync, seek to
1693 /// start. The `set_len(0)` call is the point of no return: once it
1694 /// succeeds the on-disk WAL is empty and is **no longer a replayable
1695 /// recovery marker**, even if a following fsync/seek fails.
1696 ///
1697 /// * A failure at or before `set_len(0)` returns
1698 /// [`TruncateError::BeforeMutation`] — the WAL is still intact and can be
1699 /// safely left in place for replay.
1700 /// * A failure after `set_len(0)` returns [`TruncateError::AfterMutation`]
1701 /// — the WAL has been zeroed and callers MUST NOT treat it as intact.
1702 pub fn truncate_checked(&mut self) -> std::result::Result<(), TruncateError> {
1703 // A previously poisoned WAL cannot be truncated: its cursor/size state is
1704 // untrustworthy (issue #1392, FINDING 1). Fail closed as AfterMutation so
1705 // the caller does not treat the WAL as an intact replay marker.
1706 if let Some(reason) = &self.poisoned {
1707 return Err(TruncateError::AfterMutation(Error::Storage(format!(
1708 "WAL is poisoned and cannot be truncated: {reason}"
1709 ))));
1710 }
1711
1712 // Flush any pending writes first (before mutation).
1713 self.file.flush().map_err(|e| {
1714 TruncateError::BeforeMutation(Error::Storage(format!(
1715 "Failed to flush before truncate: {}",
1716 e
1717 )))
1718 })?;
1719
1720 // Truncate the file to zero length. This is the point of no return:
1721 // after it succeeds the WAL contents are gone.
1722 self.file.get_mut().set_len(0).map_err(|e| {
1723 TruncateError::BeforeMutation(Error::Storage(format!("Failed to truncate WAL: {}", e)))
1724 })?;
1725
1726 // From here on the WAL has already been mutated (zeroed). Any failure
1727 // is AfterMutation: the WAL is no longer a valid replay marker.
1728 //
1729 // The post-mutation restore sequence (sync_all + seek(0) +
1730 // current_size=0) must be atomic-in-effect (issue #1392, FINDING 1): we
1731 // must never leave the WAL in a state where a later `append()` writes at
1732 // a stale cursor over the zeroed file (a SPARSE / misparsed WAL). We run
1733 // both fallible steps, then reconcile:
1734 //
1735 // * `seek(0)` succeeds -> the file cursor is authoritatively at offset
1736 // 0 and matches the reset `current_size`, so a subsequent append is
1737 // safe. A `sync_all` failure here only means the zeroing is not yet
1738 // durable, which we surface as AfterMutation (the WAL is still no
1739 // longer a replay marker) — but the handle remains usable.
1740 // * `seek(0)` fails -> the cursor may remain at the stale OLD
1741 // end-of-file while `current_size` was reset to 0. Rather than allow a
1742 // corrupting append, we POISON the WAL: every future
1743 // append/sync/truncate fails closed with a typed error until the WAL
1744 // is reopened (which re-establishes an authoritative cursor).
1745 let sync_res = self.sync_after_truncate();
1746 let seek_res = self.seek_after_truncate();
1747 self.current_size = 0;
1748
1749 // Post-mutation restore reconciliation (issue #1392, FINDING 1). These
1750 // checks run BEFORE the #1391 corrupt-tail guard is lifted below: on a
1751 // failure we return early with `pending_valid_prefix` still set, so the
1752 // guard is cleared ONLY after a fully successful truncate.
1753 if let Err(e) = seek_res {
1754 // Cursor position is now unknown/stale while size was reset. Poison
1755 // so no later append can silently create a sparse WAL.
1756 let reason = format!("seek after truncate failed: {e}");
1757 self.poisoned = Some(reason);
1758 return Err(TruncateError::AfterMutation(Error::Storage(format!(
1759 "Failed to seek after truncate; WAL poisoned to prevent a sparse \
1760 append: {e}"
1761 ))));
1762 }
1763
1764 // Seek succeeded: cursor is authoritatively at 0. Surface a sync failure
1765 // (durability of the zeroing) as AfterMutation, but the WAL stays usable.
1766 if let Err(e) = sync_res {
1767 return Err(TruncateError::AfterMutation(Error::Storage(format!(
1768 "Failed to sync after truncate: {}",
1769 e
1770 ))));
1771 }
1772
1773 // Clear any pending corrupt-tail guard (issue #1391, roborev r4). If this
1774 // WAL was opened over a mid-stream corrupt tail, `open_existing` recorded
1775 // the valid-prefix boundary in `pending_valid_prefix` and `append()` stays
1776 // fail-closed until it is lifted. `truncate()` has just cleared the file to
1777 // zero length and fsync'd + sought to the start, so the corrupt tail is
1778 // gone from the LIVE log — leaving the guard set would wrongly reject ALL
1779 // future appends against a now-empty WAL (deadlocked appends). The guard is
1780 // lifted only HERE, after the flush/set_len/fsync/seek steps have all
1781 // succeeded; any earlier failure returns early with the guard still set,
1782 // so the on-disk state and the fail-closed guard remain consistent.
1783 self.pending_valid_prefix = None;
1784
1785 Ok(())
1786 }
1787
1788 /// Return an error if the WAL has been poisoned by a partial
1789 /// truncate-restore failure (issue #1392, FINDING 1).
1790 fn ensure_not_poisoned(&self) -> Result<()> {
1791 if let Some(reason) = &self.poisoned {
1792 return Err(Error::Storage(format!(
1793 "WAL is poisoned after a failed truncate-restore and must be \
1794 reopened before further writes: {reason}"
1795 )));
1796 }
1797 Ok(())
1798 }
1799
1800 /// Perform the post-`set_len(0)` fsync, with a test-only fault hook.
1801 ///
1802 /// In production this simply calls `sync_all` on the underlying file. Under
1803 /// `cfg(test)` a fault can be injected (see `fail_sync_after_truncate`) so
1804 /// the `AfterMutation` state-restore path in `truncate_checked` can be
1805 /// exercised deterministically (issue #1392, FINDING 2).
1806 fn sync_after_truncate(&self) -> std::io::Result<()> {
1807 #[cfg(test)]
1808 if self.fail_sync_after_truncate {
1809 return Err(std::io::Error::other("injected post-set_len(0) sync fault"));
1810 }
1811 self.file.get_ref().sync_all()
1812 }
1813
1814 /// Perform the post-`set_len(0)` seek back to the start of the now-empty
1815 /// file, with a test-only fault hook.
1816 ///
1817 /// In production this seeks the underlying handle to offset 0. Under
1818 /// `cfg(test)` a fault can be injected (see `fail_seek_after_truncate`) so
1819 /// the poison-on-partial-failure path in `truncate_checked` can be exercised
1820 /// deterministically (issue #1392, FINDING 1).
1821 fn seek_after_truncate(&mut self) -> std::io::Result<u64> {
1822 #[cfg(test)]
1823 if self.fail_seek_after_truncate {
1824 return Err(std::io::Error::other("injected post-set_len(0) seek fault"));
1825 }
1826 self.file.get_mut().seek(SeekFrom::Start(0))
1827 }
1828
1829 /// Test-only: arm/disarm the post-truncate sync fault (issue #1392).
1830 ///
1831 /// `pub(crate)` so the sibling `write_engine` modules' `#[cfg(test)]` tests
1832 /// (e.g. the WriteEngine-level post-mutation-truncate regression) can drive
1833 /// the `AfterMutation` path through the real durability barrier.
1834 #[cfg(test)]
1835 pub(crate) fn set_fail_sync_after_truncate(&mut self, fail: bool) {
1836 self.fail_sync_after_truncate = fail;
1837 }
1838
1839 /// Test-only: arm/disarm the post-truncate seek fault (issue #1392,
1840 /// FINDING 1), driving the poison-on-partial-failure path.
1841 #[cfg(test)]
1842 pub(crate) fn set_fail_seek_after_truncate(&mut self, fail: bool) {
1843 self.fail_seek_after_truncate = fail;
1844 }
1845
1846 /// Get the current size of the WAL in bytes
1847 pub fn size(&self) -> u64 {
1848 self.current_size
1849 }
1850
1851 /// Get the path to the WAL file
1852 pub fn path(&self) -> &Path {
1853 &self.path
1854 }
1855
1856 /// Rotate the WAL (create a new one, keeping the old)
1857 ///
1858 /// This creates a new WAL file with a timestamp suffix and returns a new
1859 /// `WriteAheadLog` instance. The old WAL file is left intact for archival
1860 /// or backup purposes.
1861 ///
1862 /// The old file is renamed to: `commitlog.wal.{timestamp}`
1863 ///
1864 /// # Arguments
1865 ///
1866 /// * `dir` - Directory where the new WAL will be created
1867 ///
1868 /// # Returns
1869 ///
1870 /// A new `WriteAheadLog` instance ready for appending.
1871 ///
1872 /// # Errors
1873 ///
1874 /// Returns an error if the rotation fails.
1875 pub fn rotate(mut self, dir: &Path) -> Result<Self> {
1876 // Flush and sync the current WAL
1877 self.sync()?;
1878
1879 // Generate timestamp suffix
1880 let timestamp = std::time::SystemTime::now()
1881 .duration_since(std::time::UNIX_EPOCH)
1882 .unwrap()
1883 .as_secs();
1884
1885 let old_path = self.path.clone();
1886 let archived_path = dir.join(format!("commitlog.wal.{}", timestamp));
1887
1888 // Drop the writer to close the file
1889 drop(self.file);
1890
1891 // Rename the old WAL
1892 std::fs::rename(&old_path, &archived_path)
1893 .map_err(|e| Error::Storage(format!("Failed to rename WAL during rotation: {}", e)))?;
1894
1895 // Sync directory to ensure rename is persisted
1896 sync_directory(dir)?;
1897
1898 // Create a new WAL
1899 Self::create(dir)
1900 }
1901
1902 /// Delete an old WAL file
1903 ///
1904 /// This is used to clean up archived WAL files after a successful flush
1905 /// or when they are no longer needed for recovery.
1906 ///
1907 /// # Arguments
1908 ///
1909 /// * `path` - Path to the WAL file to delete
1910 ///
1911 /// # Errors
1912 ///
1913 /// Returns an error if the delete operation fails.
1914 pub fn delete_old(path: &Path) -> Result<()> {
1915 std::fs::remove_file(path)
1916 .map_err(|e| Error::Storage(format!("Failed to delete old WAL: {}", e)))?;
1917 Ok(())
1918 }
1919}
1920
1921#[cfg(test)]
1922mod tests {
1923 use super::*;
1924 use crate::storage::write_engine::mutation::{
1925 CellOperation, ClusteringKey, Mutation, PartitionKey, TableId,
1926 };
1927 use crate::types::Value;
1928 use tempfile::TempDir;
1929
1930 fn create_test_mutation(id: i32, name: &str) -> Mutation {
1931 let table_id = TableId::new("test_ks", "test_table");
1932 let pk = PartitionKey::single("id", Value::Integer(id));
1933 let ops = vec![CellOperation::Write {
1934 column: "name".to_string(),
1935 value: Value::text(name.to_string()),
1936 }];
1937
1938 Mutation::new(table_id, pk, None, ops, 1234567890, None)
1939 }
1940
1941 #[test]
1942 fn test_wal_create() {
1943 let temp_dir = TempDir::new().unwrap();
1944 let wal = WriteAheadLog::create(temp_dir.path()).unwrap();
1945
1946 assert_eq!(wal.size(), 0);
1947 assert!(wal.path().exists());
1948 }
1949
1950 #[test]
1951 fn test_wal_append_and_sync() {
1952 let temp_dir = TempDir::new().unwrap();
1953 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
1954
1955 let mutation = create_test_mutation(1, "Alice");
1956 wal.append(&mutation).unwrap();
1957
1958 assert!(wal.size() > 0);
1959
1960 wal.sync().unwrap();
1961 }
1962
1963 #[test]
1964 fn test_wal_replay_empty() {
1965 let temp_dir = TempDir::new().unwrap();
1966 let wal = WriteAheadLog::create(temp_dir.path()).unwrap();
1967
1968 let mutations = wal.replay().unwrap().mutations;
1969 assert_eq!(mutations.len(), 0);
1970 }
1971
1972 #[test]
1973 fn test_wal_replay_single_entry() {
1974 let temp_dir = TempDir::new().unwrap();
1975 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
1976
1977 let mutation = create_test_mutation(1, "Alice");
1978 wal.append(&mutation).unwrap();
1979 wal.sync().unwrap();
1980
1981 let mutations = wal.replay().unwrap().mutations;
1982 assert_eq!(mutations.len(), 1);
1983 assert_eq!(mutations[0].table.keyspace, "test_ks");
1984 assert_eq!(mutations[0].table.table, "test_table");
1985 }
1986
1987 #[test]
1988 fn test_wal_replay_multiple_entries() {
1989 let temp_dir = TempDir::new().unwrap();
1990 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
1991
1992 for i in 0..10 {
1993 let mutation = create_test_mutation(i, &format!("User{}", i));
1994 wal.append(&mutation).unwrap();
1995 }
1996 wal.sync().unwrap();
1997
1998 let mutations = wal.replay().unwrap().mutations;
1999 assert_eq!(mutations.len(), 10);
2000
2001 for (i, mutation) in mutations.iter().enumerate() {
2002 assert_eq!(mutation.table.keyspace, "test_ks");
2003 match &mutation.operations[0] {
2004 CellOperation::Write { column, value } => {
2005 assert_eq!(column, "name");
2006 if let Value::Text(name) = value {
2007 assert_eq!(name, &format!("User{}", i));
2008 } else {
2009 panic!("Expected Text value");
2010 }
2011 }
2012 _ => panic!("Expected Write operation"),
2013 }
2014 }
2015 }
2016
2017 #[test]
2018 fn test_wal_truncate() {
2019 let temp_dir = TempDir::new().unwrap();
2020 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2021
2022 let mutation = create_test_mutation(1, "Alice");
2023 wal.append(&mutation).unwrap();
2024 wal.sync().unwrap();
2025
2026 assert!(wal.size() > 0);
2027
2028 wal.truncate().unwrap();
2029 assert_eq!(wal.size(), 0);
2030
2031 let mutations = wal.replay().unwrap().mutations;
2032 assert_eq!(mutations.len(), 0);
2033 }
2034
2035 // Issue #1392 (FINDING 2): a `truncate_checked` whose `sync_all` fails
2036 // AFTER `set_len(0)` has already zeroed the WAL must (a) report
2037 // `AfterMutation` so the flush error propagates, and (b) still restore the
2038 // in-memory + file-handle state to offset 0. Otherwise a subsequent append
2039 // lands at the stale OLD end-of-file, producing a SPARSE WAL that replay
2040 // skips or misparses. This test injects that post-`set_len(0)` sync fault,
2041 // then performs a fresh append and verifies replay returns exactly that
2042 // write (no sparse / lost / misparsed WAL).
2043 #[test]
2044 fn truncate_post_setlen_sync_failure_resets_state_for_replay() {
2045 let temp_dir = TempDir::new().unwrap();
2046 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2047
2048 // Seed a few entries so the OLD end-of-file is well past offset 0.
2049 for i in 0..3 {
2050 wal.append(&create_test_mutation(i, &format!("Old{}", i)))
2051 .unwrap();
2052 }
2053 wal.sync().unwrap();
2054 assert!(wal.size() > 0);
2055
2056 // Arm the fault: the sync AFTER set_len(0) will fail.
2057 wal.set_fail_sync_after_truncate(true);
2058 let err = wal
2059 .truncate_checked()
2060 .expect_err("post-set_len(0) sync fault must surface");
2061 assert!(
2062 matches!(err, TruncateError::AfterMutation(_)),
2063 "a failure after set_len(0) must be AfterMutation (WAL already zeroed)"
2064 );
2065
2066 // Despite the propagated error, in-memory state was restored to the
2067 // start of the now-empty file.
2068 assert_eq!(wal.size(), 0, "current_size must be reset to 0");
2069
2070 // Disarm the fault and perform a subsequent write, as the still-usable
2071 // engine would after the propagated flush error.
2072 wal.set_fail_sync_after_truncate(false);
2073 wal.append(&create_test_mutation(42, "New")).unwrap();
2074 wal.sync().unwrap();
2075
2076 // Replay must return exactly the new entry, read from offset 0 — proving
2077 // the append landed at 0 (not the stale old EOF) and the WAL is neither
2078 // sparse nor misparsed.
2079 let mutations = wal.replay().unwrap().mutations;
2080 assert_eq!(
2081 mutations.len(),
2082 1,
2083 "replay must return exactly the post-fault write, with no sparse gap"
2084 );
2085 assert_eq!(mutations[0].table.keyspace, "test_ks");
2086 assert_eq!(
2087 mutations[0].partition_key.columns[0].1,
2088 Value::Integer(42),
2089 "the replayed entry must be the new write, correctly parsed"
2090 );
2091 }
2092
2093 // Issue #1392 (FINDING 1): if the `seek(0)` performed AFTER `set_len(0)`
2094 // fails, the file cursor may remain at the stale OLD end-of-file while
2095 // `current_size` has been reset to 0. A naive implementation would let a
2096 // later `append()` land at that stale offset over the now-zeroed file,
2097 // producing a SPARSE / misparsed WAL (silent corruption). The truncate must
2098 // instead POISON the WAL so a subsequent append fails closed rather than
2099 // corrupting it. This test injects that post-`set_len(0)` seek fault, then
2100 // proves the subsequent append (and sync) fail closed.
2101 #[test]
2102 fn truncate_post_setlen_seek_failure_poisons_wal_no_sparse_append() {
2103 let temp_dir = TempDir::new().unwrap();
2104 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2105
2106 // Seed a few entries so the OLD end-of-file is well past offset 0.
2107 for i in 0..3 {
2108 wal.append(&create_test_mutation(i, &format!("Old{i}")))
2109 .unwrap();
2110 }
2111 wal.sync().unwrap();
2112 assert!(wal.size() > 0);
2113
2114 // Arm the fault: set_len(0) + sync succeed, but the seek back to 0 fails,
2115 // leaving the cursor at the stale OLD end-of-file.
2116 wal.set_fail_seek_after_truncate(true);
2117 let err = wal
2118 .truncate_checked()
2119 .expect_err("post-set_len(0) seek fault must surface");
2120 assert!(
2121 matches!(err, TruncateError::AfterMutation(_)),
2122 "a failure after set_len(0) must be AfterMutation (WAL already zeroed)"
2123 );
2124
2125 // The WAL is now poisoned. Disarm the seek fault to prove the fail-closed
2126 // behavior comes from the poison state, NOT the injected fault.
2127 wal.set_fail_seek_after_truncate(false);
2128
2129 // A subsequent append MUST fail closed rather than silently write at the
2130 // stale cursor and create a sparse WAL.
2131 let append_err = wal
2132 .append(&create_test_mutation(42, "New"))
2133 .expect_err("append on a poisoned WAL must fail closed");
2134 assert!(
2135 matches!(append_err, Error::Storage(_)),
2136 "poisoned-WAL append must return a typed storage error, got {append_err:?}"
2137 );
2138
2139 // sync must likewise fail closed.
2140 assert!(
2141 wal.sync().is_err(),
2142 "sync on a poisoned WAL must fail closed"
2143 );
2144
2145 // And re-truncation must fail closed as AfterMutation (untrustworthy).
2146 assert!(
2147 matches!(wal.truncate_checked(), Err(TruncateError::AfterMutation(_))),
2148 "truncate on a poisoned WAL must fail closed as AfterMutation"
2149 );
2150
2151 // The on-disk WAL is empty (set_len(0) succeeded) and no corrupting
2152 // append occurred, so replay from a freshly opened handle yields nothing
2153 // — proving no sparse / misparsed entry was written.
2154 let wal_path = wal.path().to_path_buf();
2155 drop(wal);
2156 let reopened = WriteAheadLog::open_existing(&wal_path).unwrap();
2157 assert_eq!(
2158 reopened.replay().unwrap().mutations.len(),
2159 0,
2160 "no sparse / lost / misparsed entry may exist after a poisoned truncate"
2161 );
2162 }
2163
2164 #[test]
2165 fn test_wal_crc_corruption() {
2166 let temp_dir = TempDir::new().unwrap();
2167 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2168
2169 let mutation = create_test_mutation(1, "Alice");
2170 wal.append(&mutation).unwrap();
2171 wal.sync().unwrap();
2172
2173 // Corrupt the CRC32 field (bytes 4-7)
2174 let wal_path = wal.path().to_path_buf();
2175 drop(wal);
2176
2177 let mut file = OpenOptions::new().write(true).open(&wal_path).unwrap();
2178 file.seek(SeekFrom::Start(4)).unwrap();
2179 file.write_all(&[0xFF, 0xFF, 0xFF, 0xFF]).unwrap();
2180 file.sync_all().unwrap();
2181 drop(file);
2182
2183 // The corrupt (fully-present) entry must be reported, not silently
2184 // dropped: no mutation is recovered, and the report is not clean.
2185 let wal = WriteAheadLog::open_existing(&wal_path).unwrap();
2186 let report = wal.replay().unwrap();
2187 assert_eq!(report.mutations.len(), 0);
2188 assert!(
2189 !report.is_clean(),
2190 "CRC corruption must surface as non-clean"
2191 );
2192 assert_eq!(report.corrupt_entries, 1);
2193 assert!(report.stopped_early);
2194 }
2195
2196 #[test]
2197 fn test_wal_truncated_entry() {
2198 let temp_dir = TempDir::new().unwrap();
2199 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2200
2201 let mutation = create_test_mutation(1, "Alice");
2202 wal.append(&mutation).unwrap();
2203 wal.sync().unwrap();
2204
2205 let wal_path = wal.path().to_path_buf();
2206 let original_size = wal.size();
2207 drop(wal);
2208
2209 // Truncate the file to simulate incomplete write
2210 let file = OpenOptions::new().write(true).open(&wal_path).unwrap();
2211 file.set_len(original_size - 10).unwrap();
2212 drop(file);
2213
2214 // Replay should stop at truncated entry
2215 let wal = WriteAheadLog::open_existing(&wal_path).unwrap();
2216 let mutations = wal.replay().unwrap().mutations;
2217 assert_eq!(mutations.len(), 0);
2218 }
2219
2220 #[test]
2221 fn test_wal_rotate() {
2222 let temp_dir = TempDir::new().unwrap();
2223 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2224
2225 let mutation = create_test_mutation(1, "Alice");
2226 wal.append(&mutation).unwrap();
2227 wal.sync().unwrap();
2228
2229 // Rotate the WAL
2230 let wal = wal.rotate(temp_dir.path()).unwrap();
2231
2232 // New WAL should be empty
2233 assert_eq!(wal.size(), 0);
2234
2235 // Old WAL should be archived
2236 let archived_files: Vec<_> = std::fs::read_dir(temp_dir.path())
2237 .unwrap()
2238 .filter_map(|e| e.ok())
2239 .filter(|e| {
2240 e.file_name()
2241 .to_string_lossy()
2242 .starts_with("commitlog.wal.")
2243 })
2244 .collect();
2245
2246 assert_eq!(archived_files.len(), 1);
2247 }
2248
2249 #[test]
2250 fn test_wal_delete_old() {
2251 let temp_dir = TempDir::new().unwrap();
2252 let wal_path = temp_dir.path().join("test.wal");
2253
2254 // Create a dummy WAL file
2255 File::create(&wal_path).unwrap();
2256 assert!(wal_path.exists());
2257
2258 // Delete it
2259 WriteAheadLog::delete_old(&wal_path).unwrap();
2260 assert!(!wal_path.exists());
2261 }
2262
2263 #[test]
2264 fn test_wal_open_existing() {
2265 let temp_dir = TempDir::new().unwrap();
2266 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2267
2268 let mutation1 = create_test_mutation(1, "Alice");
2269 wal.append(&mutation1).unwrap();
2270 wal.sync().unwrap();
2271
2272 let wal_path = wal.path().to_path_buf();
2273 drop(wal);
2274
2275 // Reopen the WAL
2276 let mut wal = WriteAheadLog::open_existing(&wal_path).unwrap();
2277
2278 // Append another entry
2279 let mutation2 = create_test_mutation(2, "Bob");
2280 wal.append(&mutation2).unwrap();
2281 wal.sync().unwrap();
2282
2283 // Replay should get both entries
2284 let mutations = wal.replay().unwrap().mutations;
2285 assert_eq!(mutations.len(), 2);
2286 }
2287
2288 #[test]
2289 fn test_wal_with_clustering_key() {
2290 let temp_dir = TempDir::new().unwrap();
2291 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2292
2293 let table_id = TableId::new("test_ks", "test_table");
2294 let pk = PartitionKey::single("id", Value::Integer(1));
2295 let ck = Some(ClusteringKey::single("ts", Value::Timestamp(1000)));
2296 let ops = vec![CellOperation::Write {
2297 column: "value".to_string(),
2298 value: Value::text("test".to_string()),
2299 }];
2300
2301 let mutation = Mutation::new(table_id, pk, ck, ops, 1234567890, None);
2302 wal.append(&mutation).unwrap();
2303 wal.sync().unwrap();
2304
2305 let mutations = wal.replay().unwrap().mutations;
2306 assert_eq!(mutations.len(), 1);
2307 assert!(mutations[0].clustering_key.is_some());
2308 }
2309
2310 #[test]
2311 fn test_wal_with_ttl() {
2312 let temp_dir = TempDir::new().unwrap();
2313 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2314
2315 let table_id = TableId::new("test_ks", "test_table");
2316 let pk = PartitionKey::single("id", Value::Integer(1));
2317 let ops = vec![CellOperation::Write {
2318 column: "value".to_string(),
2319 value: Value::text("test".to_string()),
2320 }];
2321
2322 let mutation = Mutation::new(table_id, pk, None, ops, 1234567890, Some(3600));
2323 wal.append(&mutation).unwrap();
2324 wal.sync().unwrap();
2325
2326 let mutations = wal.replay().unwrap().mutations;
2327 assert_eq!(mutations.len(), 1);
2328 assert_eq!(mutations[0].ttl_seconds, Some(3600));
2329 }
2330
2331 #[test]
2332 fn test_wal_delete_operation() {
2333 let temp_dir = TempDir::new().unwrap();
2334 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2335
2336 let table_id = TableId::new("test_ks", "test_table");
2337 let pk = PartitionKey::single("id", Value::Integer(1));
2338 let ops = vec![CellOperation::Delete {
2339 column: "name".to_string(),
2340 local_deletion_time: None,
2341 }];
2342
2343 let mutation = Mutation::new(table_id, pk, None, ops, 1234567890, None);
2344 wal.append(&mutation).unwrap();
2345 wal.sync().unwrap();
2346
2347 let mutations = wal.replay().unwrap().mutations;
2348 assert_eq!(mutations.len(), 1);
2349 assert!(matches!(
2350 &mutations[0].operations[0],
2351 CellOperation::Delete { .. }
2352 ));
2353 }
2354
2355 #[test]
2356 fn test_wal_delete_row_operation() {
2357 let temp_dir = TempDir::new().unwrap();
2358 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2359
2360 let table_id = TableId::new("test_ks", "test_table");
2361 let pk = PartitionKey::single("id", Value::Integer(1));
2362 let ops = vec![CellOperation::DeleteRow];
2363
2364 let mutation = Mutation::new(table_id, pk, None, ops, 1234567890, None);
2365 wal.append(&mutation).unwrap();
2366 wal.sync().unwrap();
2367
2368 let mutations = wal.replay().unwrap().mutations;
2369 assert_eq!(mutations.len(), 1);
2370 assert!(matches!(
2371 &mutations[0].operations[0],
2372 CellOperation::DeleteRow
2373 ));
2374 }
2375
2376 #[test]
2377 fn test_wal_roundtrips_explicit_local_deletion_time() {
2378 // Issue #764: an explicit local_deletion_time must survive a WAL
2379 // append + replay round-trip.
2380 let temp_dir = TempDir::new().unwrap();
2381 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2382
2383 let table_id = TableId::new("test_ks", "test_table");
2384 let pk = PartitionKey::single("id", Value::Integer(7));
2385 let ops = vec![CellOperation::DeleteRow];
2386 let mutation = Mutation::new(table_id, pk, None, ops, 1_700_000_000_000_000, None)
2387 .with_local_deletion_time(1_650_000_000);
2388
2389 wal.append(&mutation).unwrap();
2390 wal.sync().unwrap();
2391
2392 let mutations = wal.replay().unwrap().mutations;
2393 assert_eq!(mutations.len(), 1);
2394 assert_eq!(
2395 mutations[0].local_deletion_time,
2396 Some(1_650_000_000),
2397 "Explicit local_deletion_time must round-trip through the WAL"
2398 );
2399 }
2400
2401 #[test]
2402 fn test_wal_default_local_deletion_time_is_none() {
2403 // Default (None) must round-trip unchanged, preserving historical
2404 // timestamp-derived behavior.
2405 let temp_dir = TempDir::new().unwrap();
2406 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2407
2408 let mutation = create_test_mutation(1, "Alice");
2409 assert_eq!(mutation.local_deletion_time, None);
2410 wal.append(&mutation).unwrap();
2411 wal.sync().unwrap();
2412
2413 let mutations = wal.replay().unwrap().mutations;
2414 assert_eq!(mutations.len(), 1);
2415 assert_eq!(mutations[0].local_deletion_time, None);
2416 }
2417
2418 #[test]
2419 fn test_wal_decodes_legacy_record_without_mutation_local_deletion_time() {
2420 // Issue #764: the WAL has no per-record version field. A record written
2421 // by an older binary (legacy Mutation layout, no Mutation-level
2422 // local_deletion_time) must still decode, upgrading to None.
2423 let legacy = LegacyMutation {
2424 table: TableId::new("ks", "tbl"),
2425 partition_key: PartitionKey::single("id", Value::Integer(3)),
2426 clustering_key: None,
2427 operations: vec![LegacyCellOperation::Delete {
2428 column: "name".to_string(),
2429 }],
2430 timestamp_micros: 1_234_567_890,
2431 ttl_seconds: None,
2432 partition_tombstone: None,
2433 range_tombstones: Vec::new(),
2434 };
2435
2436 // Serialize using the LEGACY layout.
2437 let legacy_bytes = bincode::serialize(&legacy).unwrap();
2438
2439 // The fallback must decode the legacy bytes into None.
2440 let decoded = decode_mutation(&legacy_bytes).expect("legacy record must decode");
2441 assert_eq!(decoded.local_deletion_time, None);
2442 assert_eq!(decoded.timestamp_micros, 1_234_567_890);
2443 assert!(matches!(
2444 &decoded.operations[0],
2445 CellOperation::Delete {
2446 local_deletion_time: None,
2447 ..
2448 }
2449 ));
2450 }
2451
2452 #[test]
2453 fn test_wal_decodes_legacy_delete_op_without_local_deletion_time() {
2454 // Issue #921: `CellOperation::Delete` gained a `local_deletion_time:
2455 // Option<i32>` field. bincode is positional and NOT self-describing, so a
2456 // pre-#921 record encoding `Delete { column }` (no LDT) has no bytes for
2457 // the new field. Decoding it with the NEW enum reads the following
2458 // operation's bytes as the missing Option and misaligns the record;
2459 // `#[serde(default)]` does NOT save bincode. The legacy fallback must
2460 // recover such a record as `Delete { column, local_deletion_time: None }`.
2461 //
2462 // The Delete is placed FIRST (followed by a Write) so that a naive new
2463 // decode genuinely misreads the trailing operation — this is the case
2464 // `#[serde(default)]` cannot handle and that the prior test (which used
2465 // only the new enum) failed to exercise.
2466 let legacy = LegacyMutation {
2467 table: TableId::new("ks", "tbl"),
2468 partition_key: PartitionKey::single("id", Value::Integer(7)),
2469 clustering_key: None,
2470 operations: vec![
2471 LegacyCellOperation::Delete {
2472 column: "dropped_col".to_string(),
2473 },
2474 LegacyCellOperation::Write {
2475 column: "name".to_string(),
2476 value: Value::text("Bob".to_string()),
2477 },
2478 ],
2479 timestamp_micros: 999_000,
2480 ttl_seconds: None,
2481 partition_tombstone: None,
2482 range_tombstones: Vec::new(),
2483 };
2484
2485 // Bytes as written by a pre-#921 binary.
2486 let legacy_bytes = bincode::serialize(&legacy).unwrap();
2487
2488 // Sanity: the NEW layout adds exactly one byte for the Delete's
2489 // Option<i32> discriminant (None), so the legacy bytes are genuinely a
2490 // different, shorter shape that the current decode cannot consume cleanly.
2491 let current = Mutation::new(
2492 TableId::new("ks", "tbl"),
2493 PartitionKey::single("id", Value::Integer(7)),
2494 None,
2495 vec![
2496 CellOperation::Delete {
2497 column: "dropped_col".to_string(),
2498 local_deletion_time: None,
2499 },
2500 CellOperation::Write {
2501 column: "name".to_string(),
2502 value: Value::text("Bob".to_string()),
2503 },
2504 ],
2505 999_000,
2506 None,
2507 );
2508 let current_bytes = bincode::serialize(¤t).unwrap();
2509 assert!(
2510 current_bytes.len() > legacy_bytes.len(),
2511 "Current Delete layout is strictly longer (adds the Option<i32> \
2512 discriminant for local_deletion_time): current={}, legacy={}",
2513 current_bytes.len(),
2514 legacy_bytes.len()
2515 );
2516
2517 // The legacy fallback must recover the record with both ops intact and
2518 // the Delete upgraded to local_deletion_time: None.
2519 let decoded = decode_mutation(&legacy_bytes).expect("legacy #921 record must decode");
2520 assert_eq!(decoded.timestamp_micros, 999_000);
2521 assert_eq!(decoded.operations.len(), 2);
2522 match &decoded.operations[0] {
2523 CellOperation::Delete {
2524 column,
2525 local_deletion_time,
2526 } => {
2527 assert_eq!(column, "dropped_col");
2528 assert_eq!(*local_deletion_time, None);
2529 }
2530 other => panic!("expected Delete, got {other:?}"),
2531 }
2532 match &decoded.operations[1] {
2533 CellOperation::Write { column, value } => {
2534 assert_eq!(column, "name");
2535 assert_eq!(value, &Value::text("Bob".to_string()));
2536 }
2537 other => panic!("expected Write, got {other:?}"),
2538 }
2539 }
2540
2541 #[test]
2542 fn test_wal_roundtrips_delete_with_explicit_local_deletion_time() {
2543 // The current layout must round-trip a Delete carrying an explicit
2544 // per-cell local_deletion_time (the #921 compaction path).
2545 let temp_dir = TempDir::new().unwrap();
2546 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2547
2548 let mutation = Mutation::new(
2549 TableId::new("ks", "tbl"),
2550 PartitionKey::single("id", Value::Integer(11)),
2551 None,
2552 vec![CellOperation::Delete {
2553 column: "name".to_string(),
2554 local_deletion_time: Some(1_700_000_000),
2555 }],
2556 1_700_000_000_000_000,
2557 None,
2558 );
2559 wal.append(&mutation).unwrap();
2560 wal.sync().unwrap();
2561
2562 let mutations = wal.replay().unwrap().mutations;
2563 assert_eq!(mutations.len(), 1);
2564 match &mutations[0].operations[0] {
2565 CellOperation::Delete {
2566 column,
2567 local_deletion_time,
2568 } => {
2569 assert_eq!(column, "name");
2570 assert_eq!(*local_deletion_time, Some(1_700_000_000));
2571 }
2572 other => panic!("expected Delete, got {other:?}"),
2573 }
2574 }
2575
2576 #[test]
2577 fn test_wal_decodes_layout_b_record_preserving_mutation_local_deletion_time() {
2578 // Layout (B): post-#764 / pre-#921. The mutation-level
2579 // local_deletion_time trailing field is PRESENT (Some(x)), but the
2580 // operations still use the pre-#921 `Delete { column }` shape, with the
2581 // Delete placed FIRST so a naive current decode misreads the trailing
2582 // op. Neither the current `Mutation` layout (whose Delete carries an
2583 // extra Option<i32>) nor `LegacyMutation` (which lacks the mutation-level
2584 // LDT) can decode these bytes, so the (B) compat layout is required. The
2585 // decoded mutation must preserve the mutation-level LDT AND recover both
2586 // ops with `Delete.local_deletion_time = None`.
2587 let legacy = LegacyMutationWithLdt {
2588 table: TableId::new("ks", "tbl"),
2589 partition_key: PartitionKey::single("id", Value::Integer(13)),
2590 clustering_key: None,
2591 operations: vec![
2592 LegacyCellOperation::Delete {
2593 column: "dropped_col".to_string(),
2594 },
2595 LegacyCellOperation::Write {
2596 column: "name".to_string(),
2597 value: Value::text("Carol".to_string()),
2598 },
2599 ],
2600 timestamp_micros: 1_650_000_000_000_000,
2601 ttl_seconds: None,
2602 partition_tombstone: None,
2603 range_tombstones: Vec::new(),
2604 local_deletion_time: Some(1_650_000_000),
2605 };
2606
2607 // Bytes as written by a post-#764/pre-#921 binary.
2608 let legacy_bytes = bincode::serialize(&legacy).unwrap();
2609
2610 // Sanity: the current `Mutation` layout cannot decode (B) bytes
2611 // correctly. The pre-#921 `Delete { column }` op (first in the list) has
2612 // no `local_deletion_time` byte, so a current decode either errors or
2613 // misaligns and reads a wrong number of operations — it can never
2614 // recover the two-op mutation faithfully. This is what forces the (B)
2615 // compat layout to run.
2616 let current_attempt = bincode::deserialize::<Mutation>(&legacy_bytes);
2617 let current_recovers_faithfully = matches!(
2618 ¤t_attempt,
2619 Ok(m) if m.operations.len() == 2
2620 && matches!(&m.operations[0], CellOperation::Delete { column, .. } if column == "dropped_col")
2621 && matches!(&m.operations[1], CellOperation::Write { column, value }
2622 if column == "name" && value == &Value::text("Carol".to_string()))
2623 );
2624 assert!(
2625 !current_recovers_faithfully,
2626 "current layout must NOT faithfully decode a layout (B) record; \
2627 otherwise the (B) compat path is never exercised"
2628 );
2629
2630 // The (B) compat layout must recover the record: mutation-level LDT
2631 // preserved, both ops intact, Delete upgraded to local_deletion_time: None.
2632 let decoded = decode_mutation(&legacy_bytes).expect("layout (B) record must decode");
2633 assert_eq!(decoded.local_deletion_time, Some(1_650_000_000));
2634 assert_eq!(decoded.timestamp_micros, 1_650_000_000_000_000);
2635 assert_eq!(decoded.operations.len(), 2);
2636 match &decoded.operations[0] {
2637 CellOperation::Delete {
2638 column,
2639 local_deletion_time,
2640 } => {
2641 assert_eq!(column, "dropped_col");
2642 assert_eq!(*local_deletion_time, None);
2643 }
2644 other => panic!("expected Delete, got {other:?}"),
2645 }
2646 match &decoded.operations[1] {
2647 CellOperation::Write { column, value } => {
2648 assert_eq!(column, "name");
2649 assert_eq!(value, &Value::text("Carol".to_string()));
2650 }
2651 other => panic!("expected Write, got {other:?}"),
2652 }
2653 }
2654
2655 #[test]
2656 fn test_wal_decodes_layout_e_pre_1538_write_with_ttl_preserving_trailing_fields() {
2657 // Layout (E): post-#1018 / pre-#1538. Issue #1538 appended
2658 // `local_deletion_time: Option<i32>` to `CellOperation::WriteWithTtl`
2659 // (inside `operations: Vec<CellOperation>`). A record written by the
2660 // immediately-preceding binary carries ALL current mutation-level trailing
2661 // fields (`local_deletion_time`, `row_tombstone`, `cell_write_timestamps`)
2662 // but encodes `WriteWithTtl` with the pre-#1538 3-field shape. Decoding it
2663 // as the current `Mutation` (whose `WriteWithTtl` now expects a 4th field)
2664 // misaligns.
2665 //
2666 // The `WriteWithTtl` is placed FIRST, followed by a `Delete` carrying an
2667 // explicit post-#921 `local_deletion_time: Some(..)`. This exercises two
2668 // things at once: (1) the missing per-op LDT byte genuinely misaligns a
2669 // naive current decode of the trailing op, and (2) the mirror must use the
2670 // post-#921 `Delete { column, local_deletion_time }` shape — reusing the
2671 // pre-#921 `LegacyCellOperation::Delete { column }` here would misdecode
2672 // this real mixed record (e.g. `UPDATE … USING TTL SET a=1, b=null`).
2673 let pre1538 = PreCellLdtWriteTtlMutation {
2674 table: TableId::new("ks", "tbl"),
2675 partition_key: PartitionKey::single("id", Value::Integer(17)),
2676 clustering_key: None,
2677 operations: vec![
2678 PreCellLdtWriteTtlCellOperation::WriteWithTtl {
2679 column: "session".to_string(),
2680 value: Value::text("abc".to_string()),
2681 ttl_seconds: 3600,
2682 },
2683 PreCellLdtWriteTtlCellOperation::Delete {
2684 column: "dropped_col".to_string(),
2685 local_deletion_time: Some(1_710_000_000),
2686 },
2687 ],
2688 timestamp_micros: 1_710_000_000_000_000,
2689 ttl_seconds: None,
2690 partition_tombstone: None,
2691 range_tombstones: Vec::new(),
2692 local_deletion_time: Some(1_710_000_001),
2693 row_tombstone: Some((1_710_000_002_000_000, 1_710_000_002)),
2694 cell_write_timestamps: Some(
2695 [("session".to_string(), 1_710_000_003_000_000)]
2696 .into_iter()
2697 .collect(),
2698 ),
2699 };
2700
2701 // Bytes as written by a post-#1018/pre-#1538 binary.
2702 let pre1538_bytes = bincode::serialize(&pre1538).unwrap();
2703
2704 // Sanity: the current `Mutation` layout cannot FAITHFULLY decode these
2705 // bytes. The pre-#1538 `WriteWithTtl` (first in the list) has no
2706 // `local_deletion_time` byte, so a current decode either errors or
2707 // misaligns — it can never recover the two-op mutation faithfully. This is
2708 // what forces the layout (E) compat path to run (and guards against a
2709 // silent "succeed-but-wrong" current decode).
2710 let current_attempt = bincode::deserialize::<Mutation>(&pre1538_bytes);
2711 let current_recovers_faithfully = matches!(
2712 ¤t_attempt,
2713 Ok(m) if m.operations.len() == 2
2714 && matches!(&m.operations[0],
2715 CellOperation::WriteWithTtl { column, ttl_seconds, .. }
2716 if column == "session" && *ttl_seconds == 3600)
2717 && matches!(&m.operations[1],
2718 CellOperation::Delete { column, local_deletion_time }
2719 if column == "dropped_col" && *local_deletion_time == Some(1_710_000_000))
2720 && m.local_deletion_time == Some(1_710_000_001)
2721 && m.row_tombstone == Some((1_710_000_002_000_000, 1_710_000_002))
2722 );
2723 assert!(
2724 !current_recovers_faithfully,
2725 "current layout must NOT faithfully decode a layout (E) record; \
2726 otherwise the (E) compat path is never exercised / a pre-#1538 \
2727 WriteWithTtl record would decode-but-wrong"
2728 );
2729
2730 // The (E) compat layout must recover the record: the pre-#1538
2731 // WriteWithTtl upgraded to `local_deletion_time: None`, the Delete's
2732 // post-#921 LDT preserved, and ALL mutation-level trailing fields intact.
2733 let decoded = decode_mutation(&pre1538_bytes).expect("layout (E) record must decode");
2734 assert_eq!(decoded.timestamp_micros, 1_710_000_000_000_000);
2735 assert_eq!(decoded.local_deletion_time, Some(1_710_000_001));
2736 assert_eq!(
2737 decoded.row_tombstone,
2738 Some((1_710_000_002_000_000, 1_710_000_002))
2739 );
2740 assert_eq!(
2741 decoded
2742 .cell_write_timestamps
2743 .as_ref()
2744 .and_then(|m| m.get("session").copied()),
2745 Some(1_710_000_003_000_000)
2746 );
2747 assert_eq!(decoded.operations.len(), 2);
2748 match &decoded.operations[0] {
2749 CellOperation::WriteWithTtl {
2750 column,
2751 value,
2752 ttl_seconds,
2753 local_deletion_time,
2754 } => {
2755 assert_eq!(column, "session");
2756 assert_eq!(value, &Value::text("abc".to_string()));
2757 assert_eq!(*ttl_seconds, 3600);
2758 // pre-#1538 WriteWithTtl had no surfaced source LDT → None.
2759 assert_eq!(*local_deletion_time, None);
2760 }
2761 other => panic!("expected WriteWithTtl, got {other:?}"),
2762 }
2763 match &decoded.operations[1] {
2764 CellOperation::Delete {
2765 column,
2766 local_deletion_time,
2767 } => {
2768 assert_eq!(column, "dropped_col");
2769 // The Delete's post-#921 per-cell LDT must survive — a pre-#921
2770 // LegacyCellOperation mirror would have corrupted this.
2771 assert_eq!(*local_deletion_time, Some(1_710_000_000));
2772 }
2773 other => panic!("expected Delete, got {other:?}"),
2774 }
2775 }
2776
2777 #[test]
2778 fn test_wal_decodes_layout_c_pre_1538_write_with_ttl_no_row_tombstone() {
2779 // Layout (C): post-#921 / pre-#932 (and therefore also pre-#1538). The
2780 // mutation-level `local_deletion_time` trailing field is PRESENT, but the
2781 // record predates #932's trailing `row_tombstone` AND #1018's trailing
2782 // `cell_write_timestamps`, so NEITHER trailing field exists on disk. Its
2783 // `WriteWithTtl` op is the pre-#1538 3-field shape, while its `Delete`
2784 // carries the post-#921 `local_deletion_time`. Serialized here with the
2785 // production (C) mirror, which now IS this exact era shape (3-field
2786 // `WriteWithTtl` ops via `PreCellLdtWriteTtlCellOperation`, no trailing
2787 // row-tombstone / cell-write-timestamps).
2788 let era_c = PreRowTombstoneMutation {
2789 table: TableId::new("ks", "tbl"),
2790 partition_key: PartitionKey::single("id", Value::Integer(21)),
2791 clustering_key: None,
2792 operations: vec![
2793 PreCellLdtWriteTtlCellOperation::WriteWithTtl {
2794 column: "session".to_string(),
2795 value: Value::text("cee".to_string()),
2796 ttl_seconds: 900,
2797 },
2798 PreCellLdtWriteTtlCellOperation::Delete {
2799 column: "dropped_col".to_string(),
2800 local_deletion_time: Some(1_650_000_000),
2801 },
2802 ],
2803 timestamp_micros: 1_650_000_000_000_000,
2804 ttl_seconds: None,
2805 partition_tombstone: None,
2806 range_tombstones: Vec::new(),
2807 local_deletion_time: Some(1_650_000_001),
2808 };
2809
2810 // Bytes as written by a post-#921/pre-#932 binary.
2811 let era_c_bytes = bincode::serialize(&era_c).unwrap();
2812
2813 // Sanity: the current `Mutation` layout cannot FAITHFULLY decode these
2814 // bytes. The pre-#1538 3-field `WriteWithTtl` (first in the list) has no
2815 // per-op `local_deletion_time` byte, so a current decode misaligns and can
2816 // never recover the two-op mutation faithfully. This is what makes the
2817 // silent-corruption gap real, and forces a compat mirror to run.
2818 let current_attempt = bincode::deserialize::<Mutation>(&era_c_bytes);
2819 let current_recovers_faithfully = matches!(
2820 ¤t_attempt,
2821 Ok(m) if m.operations.len() == 2
2822 && matches!(&m.operations[0],
2823 CellOperation::WriteWithTtl { column, ttl_seconds, .. }
2824 if column == "session" && *ttl_seconds == 900)
2825 && matches!(&m.operations[1],
2826 CellOperation::Delete { column, local_deletion_time }
2827 if column == "dropped_col" && *local_deletion_time == Some(1_650_000_000))
2828 );
2829 assert!(
2830 !current_recovers_faithfully,
2831 "current layout must NOT faithfully decode a layout (C) record; \
2832 otherwise a pre-#1538 WriteWithTtl would decode-but-wrong (silent \
2833 corruption gap)"
2834 );
2835
2836 // The (C) mirror must recover the record: the pre-#1538 `WriteWithTtl`
2837 // upgraded to `local_deletion_time: None`, the `Delete`'s post-#921 LDT
2838 // preserved, mutation LDT preserved, and — critically for ladder ordering
2839 // — NO trailing `row_tombstone` / `cell_write_timestamps` (both None),
2840 // proving the record landed on (C), not the more-trailing-field (D)/(E)
2841 // mirrors.
2842 let decoded = decode_mutation(&era_c_bytes).expect("layout (C) record must decode");
2843 assert_eq!(decoded.timestamp_micros, 1_650_000_000_000_000);
2844 assert_eq!(decoded.local_deletion_time, Some(1_650_000_001));
2845 assert_eq!(decoded.row_tombstone, None);
2846 assert_eq!(decoded.cell_write_timestamps, None);
2847 assert_eq!(decoded.operations.len(), 2);
2848 match &decoded.operations[0] {
2849 CellOperation::WriteWithTtl {
2850 column,
2851 value,
2852 ttl_seconds,
2853 local_deletion_time,
2854 } => {
2855 assert_eq!(column, "session");
2856 assert_eq!(value, &Value::text("cee".to_string()));
2857 assert_eq!(*ttl_seconds, 900);
2858 assert_eq!(*local_deletion_time, None);
2859 }
2860 other => panic!("expected WriteWithTtl, got {other:?}"),
2861 }
2862 match &decoded.operations[1] {
2863 CellOperation::Delete {
2864 column,
2865 local_deletion_time,
2866 } => {
2867 assert_eq!(column, "dropped_col");
2868 assert_eq!(*local_deletion_time, Some(1_650_000_000));
2869 }
2870 other => panic!("expected Delete, got {other:?}"),
2871 }
2872 }
2873
2874 #[test]
2875 fn test_wal_decodes_layout_d_pre_1538_write_with_ttl_with_row_tombstone() {
2876 // Layout (D): post-#932 / pre-#1018 (and therefore also pre-#1538). The
2877 // mutation-level `local_deletion_time` AND the #932 trailing
2878 // `row_tombstone` are PRESENT, but the record predates #1018's trailing
2879 // `cell_write_timestamps` (absent on disk). Its `WriteWithTtl` op is the
2880 // pre-#1538 3-field shape, while its `Delete` carries the post-#921
2881 // `local_deletion_time`. Serialized here with the production (D) mirror,
2882 // which now IS this exact era shape (3-field `WriteWithTtl` ops, trailing
2883 // `row_tombstone` but no `cell_write_timestamps`).
2884 let era_d = PreCellWriteTimestampsMutation {
2885 table: TableId::new("ks", "tbl"),
2886 partition_key: PartitionKey::single("id", Value::Integer(23)),
2887 clustering_key: None,
2888 operations: vec![
2889 PreCellLdtWriteTtlCellOperation::WriteWithTtl {
2890 column: "session".to_string(),
2891 value: Value::text("dee".to_string()),
2892 ttl_seconds: 1800,
2893 },
2894 PreCellLdtWriteTtlCellOperation::Delete {
2895 column: "dropped_col".to_string(),
2896 local_deletion_time: Some(1_680_000_000),
2897 },
2898 ],
2899 timestamp_micros: 1_680_000_000_000_000,
2900 ttl_seconds: None,
2901 partition_tombstone: None,
2902 range_tombstones: Vec::new(),
2903 local_deletion_time: Some(1_680_000_001),
2904 row_tombstone: Some((1_680_000_002_000_000, 1_680_000_002)),
2905 };
2906
2907 // Bytes as written by a post-#932/pre-#1018 binary.
2908 let era_d_bytes = bincode::serialize(&era_d).unwrap();
2909
2910 // Sanity: the current `Mutation` layout cannot FAITHFULLY decode these
2911 // bytes — the pre-#1538 3-field `WriteWithTtl` (first op) misaligns the
2912 // decode (silent-corruption gap).
2913 let current_attempt = bincode::deserialize::<Mutation>(&era_d_bytes);
2914 let current_recovers_faithfully = matches!(
2915 ¤t_attempt,
2916 Ok(m) if m.operations.len() == 2
2917 && matches!(&m.operations[0],
2918 CellOperation::WriteWithTtl { column, ttl_seconds, .. }
2919 if column == "session" && *ttl_seconds == 1800)
2920 && matches!(&m.operations[1],
2921 CellOperation::Delete { column, local_deletion_time }
2922 if column == "dropped_col" && *local_deletion_time == Some(1_680_000_000))
2923 && m.row_tombstone == Some((1_680_000_002_000_000, 1_680_000_002))
2924 );
2925 assert!(
2926 !current_recovers_faithfully,
2927 "current layout must NOT faithfully decode a layout (D) record; \
2928 otherwise a pre-#1538 WriteWithTtl would decode-but-wrong (silent \
2929 corruption gap)"
2930 );
2931
2932 // The (D) mirror must recover the record: `WriteWithTtl` LDT upgraded to
2933 // None, `Delete` LDT preserved, mutation LDT preserved, the #932
2934 // `row_tombstone` preserved, and — proving it landed on (D) not (E) — NO
2935 // trailing `cell_write_timestamps` (None).
2936 let decoded = decode_mutation(&era_d_bytes).expect("layout (D) record must decode");
2937 assert_eq!(decoded.timestamp_micros, 1_680_000_000_000_000);
2938 assert_eq!(decoded.local_deletion_time, Some(1_680_000_001));
2939 assert_eq!(
2940 decoded.row_tombstone,
2941 Some((1_680_000_002_000_000, 1_680_000_002))
2942 );
2943 assert_eq!(decoded.cell_write_timestamps, None);
2944 assert_eq!(decoded.operations.len(), 2);
2945 match &decoded.operations[0] {
2946 CellOperation::WriteWithTtl {
2947 column,
2948 value,
2949 ttl_seconds,
2950 local_deletion_time,
2951 } => {
2952 assert_eq!(column, "session");
2953 assert_eq!(value, &Value::text("dee".to_string()));
2954 assert_eq!(*ttl_seconds, 1800);
2955 assert_eq!(*local_deletion_time, None);
2956 }
2957 other => panic!("expected WriteWithTtl, got {other:?}"),
2958 }
2959 match &decoded.operations[1] {
2960 CellOperation::Delete {
2961 column,
2962 local_deletion_time,
2963 } => {
2964 assert_eq!(column, "dropped_col");
2965 assert_eq!(*local_deletion_time, Some(1_680_000_000));
2966 }
2967 other => panic!("expected Delete, got {other:?}"),
2968 }
2969 }
2970
2971 #[test]
2972 fn test_wal_current_write_with_ttl_ldt_decodes_via_current_layout() {
2973 // Guard the other direction: a CURRENT record whose `WriteWithTtl` carries
2974 // an explicit `local_deletion_time: Some(..)` (the #1538 compaction path)
2975 // must still decode via the current layout (layout 1) and round-trip that
2976 // LDT verbatim — adding the layout (E) fallback must NOT regress it.
2977 let temp_dir = TempDir::new().unwrap();
2978 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
2979
2980 let mut mutation = Mutation::new(
2981 TableId::new("ks", "tbl"),
2982 PartitionKey::single("id", Value::Integer(19)),
2983 None,
2984 vec![CellOperation::WriteWithTtl {
2985 column: "session".to_string(),
2986 value: Value::text("xyz".to_string()),
2987 ttl_seconds: 7200,
2988 local_deletion_time: Some(1_720_007_200),
2989 }],
2990 1_720_000_000_000_000,
2991 None,
2992 );
2993 mutation.local_deletion_time = Some(1_720_000_000);
2994 wal.append(&mutation).unwrap();
2995 wal.sync().unwrap();
2996
2997 let mutations = wal.replay().unwrap().mutations;
2998 assert_eq!(mutations.len(), 1);
2999 assert_eq!(mutations[0].local_deletion_time, Some(1_720_000_000));
3000 match &mutations[0].operations[0] {
3001 CellOperation::WriteWithTtl {
3002 column,
3003 ttl_seconds,
3004 local_deletion_time,
3005 ..
3006 } => {
3007 assert_eq!(column, "session");
3008 assert_eq!(*ttl_seconds, 7200);
3009 assert_eq!(*local_deletion_time, Some(1_720_007_200));
3010 }
3011 other => panic!("expected WriteWithTtl, got {other:?}"),
3012 }
3013 }
3014
3015 #[test]
3016 fn test_wal_buffer_size() {
3017 let temp_dir = TempDir::new().unwrap();
3018 let wal = WriteAheadLog::create_with_buffer_size(temp_dir.path(), 8192).unwrap();
3019
3020 assert_eq!(wal.buffer_size, 8192);
3021 }
3022
3023 #[test]
3024 fn test_wal_directory_sync_on_create() {
3025 // Test that directory is synced after WAL creation
3026 let temp_dir = TempDir::new().unwrap();
3027 let wal = WriteAheadLog::create(temp_dir.path()).unwrap();
3028
3029 // Verify WAL file exists
3030 assert!(wal.path().exists());
3031
3032 // The sync operation should have completed without error
3033 // (we can't directly test that fsync was called, but we verify no error)
3034 }
3035
3036 #[test]
3037 fn test_wal_directory_sync_on_rotate() {
3038 // Test that directory is synced after WAL rotation
3039 let temp_dir = TempDir::new().unwrap();
3040 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
3041
3042 let mutation = create_test_mutation(1, "Alice");
3043 wal.append(&mutation).unwrap();
3044 wal.sync().unwrap();
3045
3046 // Rotate WAL
3047 let new_wal = wal.rotate(temp_dir.path()).unwrap();
3048
3049 // Verify new WAL exists
3050 assert!(new_wal.path().exists());
3051
3052 // Verify archived WAL exists
3053 let archived_files: Vec<_> = std::fs::read_dir(temp_dir.path())
3054 .unwrap()
3055 .filter_map(|e| e.ok())
3056 .filter(|e| {
3057 e.file_name()
3058 .to_string_lossy()
3059 .starts_with("commitlog.wal.")
3060 })
3061 .collect();
3062
3063 assert_eq!(archived_files.len(), 1);
3064 }
3065
3066 #[test]
3067 fn test_wal_fsync_after_truncate() {
3068 // Test that fsync is called after truncate
3069 let temp_dir = TempDir::new().unwrap();
3070 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
3071
3072 let mutation = create_test_mutation(1, "Alice");
3073 wal.append(&mutation).unwrap();
3074 wal.sync().unwrap();
3075
3076 let size_before = wal.size();
3077 assert!(size_before > 0);
3078
3079 // Truncate should sync to disk
3080 wal.truncate().unwrap();
3081
3082 assert_eq!(wal.size(), 0);
3083
3084 // Verify file is actually empty
3085 let metadata = std::fs::metadata(wal.path()).unwrap();
3086 assert_eq!(metadata.len(), 0);
3087 }
3088
3089 #[test]
3090 fn test_validate_wal_directory_nonexistent() {
3091 // Test that validation fails for non-existent directory
3092 let nonexistent = PathBuf::from("/nonexistent/path/that/does/not/exist");
3093 let result = validate_wal_directory(&nonexistent);
3094
3095 assert!(result.is_err());
3096 match result {
3097 Err(Error::InvalidPath(_)) => {}
3098 _ => panic!("Expected InvalidPath error"),
3099 }
3100 }
3101
3102 #[test]
3103 fn test_validate_wal_directory_is_file() {
3104 // Test that validation fails when path is a file, not a directory
3105 let temp_dir = TempDir::new().unwrap();
3106 let file_path = temp_dir.path().join("not_a_dir");
3107 File::create(&file_path).unwrap();
3108
3109 let result = validate_wal_directory(&file_path);
3110
3111 assert!(result.is_err());
3112 match result {
3113 Err(Error::InvalidPath(_)) => {}
3114 _ => panic!("Expected InvalidPath error"),
3115 }
3116 }
3117
3118 #[test]
3119 fn test_validate_wal_directory_valid() {
3120 // Test that validation succeeds for valid directory
3121 let temp_dir = TempDir::new().unwrap();
3122 let result = validate_wal_directory(temp_dir.path());
3123
3124 assert!(result.is_ok());
3125 let canonical = result.unwrap();
3126 assert!(canonical.is_absolute());
3127 }
3128
3129 #[test]
3130 #[cfg(unix)]
3131 fn test_wal_file_permissions() {
3132 use std::os::unix::fs::PermissionsExt;
3133
3134 // Test that WAL files have secure permissions (0o600) on Unix
3135 let temp_dir = TempDir::new().unwrap();
3136 let wal = WriteAheadLog::create(temp_dir.path()).unwrap();
3137
3138 let metadata = std::fs::metadata(wal.path()).unwrap();
3139 let permissions = metadata.permissions();
3140 let mode = permissions.mode();
3141
3142 // Check that permissions are 0o600 (owner read/write only)
3143 // Mask with 0o777 to get only permission bits
3144 assert_eq!(mode & 0o777, 0o600);
3145 }
3146
3147 #[test]
3148 fn test_wal_create_validates_directory() {
3149 // Test that WAL creation validates the directory path
3150 let temp_dir = TempDir::new().unwrap();
3151
3152 // This should succeed because temp_dir exists
3153 let result = WriteAheadLog::create(temp_dir.path());
3154 assert!(result.is_ok());
3155
3156 // This should fail because the directory doesn't exist
3157 let nonexistent = temp_dir.path().join("nonexistent");
3158 let result = WriteAheadLog::create(&nonexistent);
3159 assert!(result.is_err());
3160 }
3161
3162 // ---- Issue #1390: torn-tail trim on open_existing -------------------
3163 //
3164 // `open_existing` must scan the log and position/truncate to the end of the
3165 // last CRC-valid entry so that a partial (torn) tail left by a crash is not
3166 // retained AHEAD of future appends. Otherwise every post-reopen append lands
3167 // after the garbage and is silently unrecoverable on the next replay.
3168
3169 /// Append A then B (both synced), return (path, end_of_A_offset, total_size).
3170 fn write_two_entries(temp_dir: &TempDir) -> (PathBuf, u64, u64) {
3171 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
3172
3173 wal.append(&create_test_mutation(1, "Alice")).unwrap();
3174 wal.sync().unwrap();
3175 let end_of_a = wal.size();
3176
3177 wal.append(&create_test_mutation(2, "Bob")).unwrap();
3178 wal.sync().unwrap();
3179 let total = wal.size();
3180
3181 let path = wal.path().to_path_buf();
3182 drop(wal);
3183 (path, end_of_a, total)
3184 }
3185
3186 /// Criterion 1 (updated for issue #1391 r5): a COMPLETE-header entry with a
3187 /// short payload is NOT a clean torn tail. A valid A + B with a complete
3188 /// 8-byte header but a truncated payload is structurally indistinguishable
3189 /// from a length bit-flip that overshoots EOF (both: complete header,
3190 /// declared length > remaining bytes, at the physical tail), so open_existing
3191 /// must PRESERVE it (leave the bytes on disk, record a pending valid prefix)
3192 /// rather than silently trimming it back to A and reporting clean. Only a
3193 /// torn HEADER (< 8 bytes, see the torn-header criterion) is trimmed as clean.
3194 #[test]
3195 fn test_wal_open_existing_preserves_complete_header_short_payload() {
3196 let temp_dir = TempDir::new().unwrap();
3197 let (path, end_of_a, total) = write_two_entries(&temp_dir);
3198
3199 // Cut B mid-payload: keep its 8-byte header + 2 payload bytes.
3200 let torn_len = end_of_a + 10;
3201 assert!(
3202 torn_len < total,
3203 "test setup: B must be longer than 2 payload bytes"
3204 );
3205 let file = OpenOptions::new().write(true).open(&path).unwrap();
3206 file.set_len(torn_len).unwrap();
3207 file.sync_all().unwrap();
3208 drop(file);
3209
3210 let wal = WriteAheadLog::open_existing(&path).unwrap();
3211
3212 // The complete-header short-payload entry must be PRESERVED, not trimmed:
3213 // the file keeps its bytes and a pending valid prefix is recorded.
3214 assert_eq!(
3215 std::fs::metadata(&path).unwrap().len(),
3216 torn_len,
3217 "a complete-header short-payload entry must be preserved on disk, not trimmed"
3218 );
3219 assert!(
3220 wal.has_pending_corrupt_tail(),
3221 "a complete-header short-payload entry must record a pending valid prefix"
3222 );
3223
3224 // Replay recovers only A and reports the loss (not clean).
3225 let report = wal.replay().unwrap();
3226 assert_eq!(report.mutations.len(), 1, "only A is recovered");
3227 assert!(
3228 !report.is_clean(),
3229 "the dropped complete-header entry is lossy"
3230 );
3231 assert!(report.stopped_early);
3232 }
3233
3234 /// Criterion 2: post-reopen writes recoverable. Continuing criterion 1 —
3235 /// after reopen the complete-header short-payload B is preserved pending, so
3236 /// (issue #1391 r5) the caller must reset to the valid prefix before
3237 /// appending C; replay must then yield exactly [A, C] with C PRESENT. This is
3238 /// the guarantee that C lands at a replayable position, not after retained
3239 /// torn bytes where it would be lost on the next replay.
3240 #[test]
3241 fn test_wal_post_reopen_write_is_recoverable() {
3242 let temp_dir = TempDir::new().unwrap();
3243 let (path, end_of_a, total) = write_two_entries(&temp_dir);
3244
3245 let torn_len = end_of_a + 10;
3246 assert!(torn_len < total);
3247 let file = OpenOptions::new().write(true).open(&path).unwrap();
3248 file.set_len(torn_len).unwrap();
3249 file.sync_all().unwrap();
3250 drop(file);
3251
3252 // Reopen: B (complete header, short payload) is preserved pending. Reset
3253 // to the valid prefix (end of A) so the append lands at a replayable
3254 // position, then append C, sync, drop.
3255 {
3256 let mut wal = WriteAheadLog::open_existing(&path).unwrap();
3257 let reset_to = wal.reset_to_valid_prefix().unwrap();
3258 assert_eq!(reset_to, Some(end_of_a), "reset trims back to the end of A");
3259 wal.append(&create_test_mutation(3, "Carol")).unwrap();
3260 wal.sync().unwrap();
3261 }
3262
3263 // Fresh reopen + replay must recover exactly [A, C].
3264 let wal = WriteAheadLog::open_existing(&path).unwrap();
3265 let mutations = wal.replay().unwrap().mutations;
3266 assert_eq!(
3267 mutations.len(),
3268 2,
3269 "must recover A and C (C must be present)"
3270 );
3271
3272 let names: Vec<&str> = mutations
3273 .iter()
3274 .map(|m| match &m.operations[0] {
3275 CellOperation::Write {
3276 value: Value::Text(name),
3277 ..
3278 } => std::str::from_utf8(name).unwrap_or_default(),
3279 other => panic!("expected Write op, got {other:?}"),
3280 })
3281 .collect();
3282 assert_eq!(names, vec!["Alice", "Carol"]);
3283 }
3284
3285 /// Criterion 3: torn-header variant. Tail cut mid-header (< 8 bytes) — same
3286 /// expected outcomes: trim to the end of A and remain appendable.
3287 #[test]
3288 fn test_wal_open_existing_trims_torn_header() {
3289 let temp_dir = TempDir::new().unwrap();
3290 let (path, end_of_a, _total) = write_two_entries(&temp_dir);
3291
3292 // Cut mid-header: keep A + only 3 bytes of B's 8-byte header.
3293 let torn_len = end_of_a + 3;
3294 let file = OpenOptions::new().write(true).open(&path).unwrap();
3295 file.set_len(torn_len).unwrap();
3296 file.sync_all().unwrap();
3297 drop(file);
3298
3299 {
3300 let mut wal = WriteAheadLog::open_existing(&path).unwrap();
3301 assert_eq!(
3302 std::fs::metadata(&path).unwrap().len(),
3303 end_of_a,
3304 "torn header must be trimmed to the last CRC-valid boundary"
3305 );
3306 wal.append(&create_test_mutation(3, "Carol")).unwrap();
3307 wal.sync().unwrap();
3308 }
3309
3310 let wal = WriteAheadLog::open_existing(&path).unwrap();
3311 let mutations = wal.replay().unwrap().mutations;
3312 assert_eq!(
3313 mutations.len(),
3314 2,
3315 "must recover A and C after torn-header trim"
3316 );
3317 }
3318
3319 /// Criterion 4: clean log unaffected. open_existing on a clean log preserves
3320 /// all entries and appends normally (regression guard for the trim logic).
3321 #[test]
3322 fn test_wal_open_existing_clean_log_unaffected() {
3323 let temp_dir = TempDir::new().unwrap();
3324 let (path, _end_of_a, total) = write_two_entries(&temp_dir);
3325
3326 {
3327 let wal = WriteAheadLog::open_existing(&path).unwrap();
3328 assert_eq!(
3329 std::fs::metadata(&path).unwrap().len(),
3330 total,
3331 "a clean log must not be truncated"
3332 );
3333 assert_eq!(wal.size(), total);
3334 let mutations = wal.replay().unwrap().mutations;
3335 assert_eq!(mutations.len(), 2, "clean log preserves all entries");
3336 }
3337
3338 // Appending after a clean reopen must extend the log normally.
3339 {
3340 let mut wal = WriteAheadLog::open_existing(&path).unwrap();
3341 wal.append(&create_test_mutation(3, "Carol")).unwrap();
3342 wal.sync().unwrap();
3343 }
3344 let wal = WriteAheadLog::open_existing(&path).unwrap();
3345 assert_eq!(wal.replay().unwrap().mutations.len(), 3);
3346 }
3347
3348 /// Regression (#1390 roborev finding, superseded by #1391): a COMPLETE but
3349 /// CRC-corrupt entry in the MIDDLE of the log is NOT a torn tail, so
3350 /// `open_existing` must NOT truncate at it — the acknowledged bytes after it
3351 /// must survive on disk. #1390 originally recovered them by skipping the
3352 /// corrupt entry by its (untrusted) declared length and continuing; #1391
3353 /// SUPERSEDES that with fail-fast-then-report: because the length field is not
3354 /// CRC-protected, trusting it to locate the next entry is a heuristic, so
3355 /// replay STOPS at the corruption, reports it (never silent), and the segment
3356 /// (including any successors) is PRESERVED on disk for evidence rather than
3357 /// recovered.
3358 ///
3359 /// WAL = [valid A][complete CRC-corrupt B][valid C]: the file length is
3360 /// preserved through C (no truncation), `open_existing` leaves the corrupt
3361 /// tail pending (not trimmed), and `replay` surfaces the valid prefix [A] plus
3362 /// a non-clean, stopped-early report. Before #1390, `scan_last_valid_offset`
3363 /// stopped at B's CRC mismatch and `open_existing` truncated back to the end
3364 /// of A, permanently and SILENTLY discarding the acknowledged C.
3365 #[test]
3366 fn test_wal_open_existing_preserves_entries_after_midstream_crc_corruption() {
3367 let temp_dir = TempDir::new().unwrap();
3368
3369 // [A][B][C], all synced. Capture the end-of-A offset (start of B's frame)
3370 // and the total size.
3371 let (path, end_of_a, total) = {
3372 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
3373 wal.append(&create_test_mutation(1, "Alice")).unwrap();
3374 wal.sync().unwrap();
3375 let end_of_a = wal.size();
3376 wal.append(&create_test_mutation(2, "Bob")).unwrap();
3377 wal.sync().unwrap();
3378 wal.append(&create_test_mutation(3, "Carol")).unwrap();
3379 wal.sync().unwrap();
3380 let total = wal.size();
3381 let path = wal.path().to_path_buf();
3382 drop(wal);
3383 (path, end_of_a, total)
3384 };
3385
3386 // Corrupt B's CRC (the 4 bytes at end_of_a+4) with a guaranteed-different
3387 // value via a bit flip. B stays structurally complete (header + full
3388 // payload intact); only its checksum is wrong.
3389 let mut file = OpenOptions::new()
3390 .read(true)
3391 .write(true)
3392 .open(&path)
3393 .unwrap();
3394 file.seek(SeekFrom::Start(end_of_a + 4)).unwrap();
3395 let mut crc = [0u8; 4];
3396 file.read_exact(&mut crc).unwrap();
3397 crc[0] ^= 0xFF;
3398 file.seek(SeekFrom::Start(end_of_a + 4)).unwrap();
3399 file.write_all(&crc).unwrap();
3400 file.sync_all().unwrap();
3401 drop(file);
3402
3403 // open_existing must NOT truncate: B is complete and has bytes (C) after
3404 // it, so it is not a torn tail. Under #1391 the corrupt tail is left
3405 // pending (preserved for evidence), not silently trimmed.
3406 let wal = WriteAheadLog::open_existing(&path).unwrap();
3407 assert_eq!(
3408 std::fs::metadata(&path).unwrap().len(),
3409 total,
3410 "a mid-stream CRC-corrupt (but structurally complete) entry must NOT be trimmed"
3411 );
3412 assert_eq!(wal.size(), total);
3413 assert!(
3414 wal.has_pending_corrupt_tail(),
3415 "mid-stream corruption must be recorded as a pending corrupt tail, not trimmed away"
3416 );
3417
3418 // replay surfaces the valid prefix [A] and REPORTS the mid-stream
3419 // corruption (never silent): it stops at B rather than trusting B's
3420 // uncovered length to skip to C. C is preserved on disk (see file-length
3421 // assertion above) and surfaced via the non-clean report, not lost.
3422 let report = wal.replay().unwrap();
3423 assert!(
3424 !report.is_clean(),
3425 "mid-stream CRC corruption must be reported, not silently swallowed"
3426 );
3427 assert!(
3428 report.stopped_early,
3429 "replay must stop at the untrusted mid-stream corruption"
3430 );
3431 assert_eq!(report.corrupt_entries, 1, "B must be reported corrupt");
3432 let names: Vec<&str> = report
3433 .mutations
3434 .iter()
3435 .map(|m| match &m.operations[0] {
3436 CellOperation::Write {
3437 value: Value::Text(name),
3438 ..
3439 } => std::str::from_utf8(name).unwrap_or_default(),
3440 other => panic!("expected Write op, got {other:?}"),
3441 })
3442 .collect();
3443 assert_eq!(
3444 names,
3445 vec!["Alice"],
3446 "replay recovers the valid prefix [A] and reports the rest as lossy (never silent)"
3447 );
3448 }
3449
3450 /// Issue #1391 (Finding A): a WAL that is JUST a torn FIRST header — 1..=7
3451 /// garbage bytes at offset 0 with no valid entry before them — must be
3452 /// classified as a torn tail and trimmed to 0 on open, exactly like a torn
3453 /// header after a valid entry. The prior `offset == 0` short-circuit
3454 /// misclassified it as a clean EOF, so the garbage was left in place and
3455 /// every later acknowledged append landed after it (unrecoverable on the
3456 /// next replay). Verify: trim to 0, append C, replay yields exactly [C].
3457 #[test]
3458 fn test_wal_open_existing_trims_torn_first_header() {
3459 for garbage_len in 1..8usize {
3460 let temp_dir = TempDir::new().unwrap();
3461 let path = temp_dir.path().join(WriteAheadLog::WAL_FILENAME);
3462
3463 // Write ONLY a partial (torn) 8-byte header — nothing valid precedes it.
3464 std::fs::write(&path, vec![0xABu8; garbage_len]).unwrap();
3465
3466 // Reopen: the torn first header must be trimmed back to offset 0.
3467 {
3468 let mut wal = WriteAheadLog::open_existing(&path).unwrap();
3469 assert_eq!(
3470 std::fs::metadata(&path).unwrap().len(),
3471 0,
3472 "torn first header ({garbage_len} byte(s)) must be trimmed to 0, not kept"
3473 );
3474 assert_eq!(wal.size(), 0);
3475 assert!(
3476 !wal.has_pending_corrupt_tail(),
3477 "a torn tail is trimmed in open, not left pending"
3478 );
3479
3480 // Append C after the trim, synced.
3481 wal.append(&create_test_mutation(3, "Carol")).unwrap();
3482 wal.sync().unwrap();
3483 }
3484
3485 // Fresh reopen + replay must recover EXACTLY [C]: the append landed at
3486 // offset 0, not after the garbage.
3487 let wal = WriteAheadLog::open_existing(&path).unwrap();
3488 let report = wal.replay().unwrap();
3489 assert!(
3490 report.is_clean(),
3491 "recovery must be clean after trimming the torn first header \
3492 (garbage_len={garbage_len})"
3493 );
3494 let names: Vec<&str> = report
3495 .mutations
3496 .iter()
3497 .map(|m| match &m.operations[0] {
3498 CellOperation::Write {
3499 value: Value::Text(name),
3500 ..
3501 } => std::str::from_utf8(name).unwrap_or_default(),
3502 other => panic!("expected Write op, got {other:?}"),
3503 })
3504 .collect();
3505 assert_eq!(
3506 names,
3507 vec!["Carol"],
3508 "replay must yield exactly [C] (garbage_len={garbage_len})"
3509 );
3510 }
3511 }
3512
3513 /// Issue #1391 (roborev r2, Finding 2): a WAL opened with a mid-stream
3514 /// corrupt tail (`pending_valid_prefix` set) must REFUSE `append()` until the
3515 /// caller resets to the valid prefix. A direct public-WAL-API consumer that
3516 /// does not go through `WriteEngine::new` (which resets first) would otherwise
3517 /// append AFTER the corrupt entry — where the next `replay()` stops — silently
3518 /// losing the acknowledged write. Verify: append errors while pending; after
3519 /// `reset_to_valid_prefix()` the append succeeds and replays as [A, C].
3520 #[test]
3521 fn test_wal_append_rejected_until_reset_after_midstream_corruption() {
3522 let temp_dir = TempDir::new().unwrap();
3523 let (path, end_of_a, total) = write_two_entries(&temp_dir);
3524
3525 // Corrupt B's payload IN PLACE (flip its first payload byte) without
3526 // changing any length — a fully-present entry with a bad CRC, which
3527 // `scan_valid_prefix` classifies as `Corruption` (not a torn tail). The
3528 // valid prefix ends at A, so `open_existing` records a pending corrupt
3529 // tail rather than trimming it away.
3530 let b_first_payload = end_of_a + 8;
3531 assert!(
3532 b_first_payload < total,
3533 "test setup: B must have at least one payload byte"
3534 );
3535 {
3536 let mut bytes = std::fs::read(&path).unwrap();
3537 bytes[b_first_payload as usize] ^= 0xFF;
3538 std::fs::write(&path, &bytes).unwrap();
3539 }
3540
3541 // Open: the corrupt segment is left intact (evidence) and the valid
3542 // prefix (end of A) is recorded as pending.
3543 let mut wal = WriteAheadLog::open_existing(&path).unwrap();
3544 assert!(
3545 wal.has_pending_corrupt_tail(),
3546 "mid-stream corruption must leave a pending valid prefix, not silently trim"
3547 );
3548 assert_eq!(
3549 std::fs::metadata(&path).unwrap().len(),
3550 total,
3551 "the corrupt segment must remain on disk for evidence preservation"
3552 );
3553
3554 // Finding 2: appending BEFORE reset must fail-closed (otherwise the write
3555 // lands after the corrupt tail and is lost on the next replay).
3556 let err = wal
3557 .append(&create_test_mutation(3, "Carol"))
3558 .expect_err("append must be rejected while a corrupt tail is unreset");
3559 match err {
3560 Error::Storage(msg) => assert!(
3561 msg.contains("reset_to_valid_prefix"),
3562 "error must direct the caller to reset first, got: {msg}"
3563 ),
3564 other => panic!("expected Error::Storage, got {other:?}"),
3565 }
3566
3567 // Reset to the valid prefix (clears the pending flag) and confirm the
3568 // corrupt tail is gone from the LIVE log.
3569 let reset_to = wal.reset_to_valid_prefix().unwrap();
3570 assert_eq!(reset_to, Some(end_of_a), "reset trims back to the end of A");
3571 assert!(
3572 !wal.has_pending_corrupt_tail(),
3573 "reset_to_valid_prefix must clear the pending flag"
3574 );
3575
3576 // After reset, append succeeds and lands at the valid boundary.
3577 wal.append(&create_test_mutation(3, "Carol")).unwrap();
3578 wal.sync().unwrap();
3579 drop(wal);
3580
3581 // Fresh reopen + replay must recover EXACTLY [A, C]: C landed at end_of_a,
3582 // not after the (now removed) corrupt B.
3583 let wal = WriteAheadLog::open_existing(&path).unwrap();
3584 let report = wal.replay().unwrap();
3585 assert!(
3586 report.is_clean(),
3587 "recovery must be clean after reset + post-recovery append"
3588 );
3589 let names: Vec<&str> = report
3590 .mutations
3591 .iter()
3592 .map(|m| match &m.operations[0] {
3593 CellOperation::Write {
3594 value: Value::Text(name),
3595 ..
3596 } => std::str::from_utf8(name).unwrap_or_default(),
3597 other => panic!("expected Write op, got {other:?}"),
3598 })
3599 .collect();
3600 assert_eq!(
3601 names,
3602 vec!["Alice", "Carol"],
3603 "replay must yield exactly [A, C]"
3604 );
3605 }
3606
3607 /// Issue #1391 (roborev r4): discarding a pending corrupt tail via `truncate()`
3608 /// must lift the fail-closed append guard. `truncate()` clears the file to zero
3609 /// length; an empty WAL has no corrupt tail, so `pending_valid_prefix` must not
3610 /// remain set — otherwise every subsequent `append()` is rejected forever
3611 /// (deadlocked appends) even though the live log is empty. Red-before: the guard
3612 /// stayed set after truncate and the append errored. Green-after: the guard is
3613 /// cleared and the append succeeds and replays.
3614 #[test]
3615 fn test_wal_truncate_clears_pending_corrupt_tail_guard() {
3616 let temp_dir = TempDir::new().unwrap();
3617 let (path, end_of_a, total) = write_two_entries(&temp_dir);
3618
3619 // Corrupt B's payload in place so `open_existing` records a pending corrupt
3620 // tail (valid prefix ends at A) rather than trimming a torn tail.
3621 let b_first_payload = end_of_a + 8;
3622 assert!(
3623 b_first_payload < total,
3624 "test setup: B must have at least one payload byte"
3625 );
3626 {
3627 let mut bytes = std::fs::read(&path).unwrap();
3628 bytes[b_first_payload as usize] ^= 0xFF;
3629 std::fs::write(&path, &bytes).unwrap();
3630 }
3631
3632 let mut wal = WriteAheadLog::open_existing(&path).unwrap();
3633 assert!(
3634 wal.has_pending_corrupt_tail(),
3635 "mid-stream corruption must leave a pending valid prefix"
3636 );
3637
3638 // Discard the corrupt tail wholesale via truncate() (as a direct WAL user
3639 // choosing to drop the log rather than reset-to-prefix).
3640 wal.truncate().unwrap();
3641
3642 // After truncate the live WAL is empty, so the fail-closed guard MUST be
3643 // lifted (red-before: guard stayed set here).
3644 assert!(
3645 !wal.has_pending_corrupt_tail(),
3646 "truncate() must clear the pending corrupt-tail guard on an emptied WAL"
3647 );
3648 assert_eq!(wal.size(), 0, "truncate() must reset current_size to zero");
3649
3650 // A subsequent append must succeed (red-before: rejected by the stale guard)
3651 // and land at offset 0 in the emptied log.
3652 wal.append(&create_test_mutation(3, "Carol")).unwrap();
3653 wal.sync().unwrap();
3654 drop(wal);
3655
3656 // Fresh reopen + replay must recover EXACTLY [C]: the truncate dropped both
3657 // A and the corrupt B, and C landed at the start of the emptied log.
3658 let wal = WriteAheadLog::open_existing(&path).unwrap();
3659 let report = wal.replay().unwrap();
3660 assert!(
3661 report.is_clean(),
3662 "recovery must be clean after truncate + post-truncate append"
3663 );
3664 let names: Vec<&str> = report
3665 .mutations
3666 .iter()
3667 .map(|m| match &m.operations[0] {
3668 CellOperation::Write {
3669 value: Value::Text(name),
3670 ..
3671 } => std::str::from_utf8(name).unwrap_or_default(),
3672 other => panic!("expected Write op, got {other:?}"),
3673 })
3674 .collect();
3675 assert_eq!(names, vec!["Carol"], "replay must yield exactly [C]");
3676 }
3677
3678 /// Finding 1 (roborev r3): the write path's accepted max entry size MUST equal
3679 /// the replay/scan limit. An entry whose serialized length exceeds
3680 /// `MAX_ENTRY_LENGTH` must be rejected BEFORE it is written (never
3681 /// fsync-acknowledged), otherwise `replay()` would classify it as corruption
3682 /// and drop it — silent acknowledged-write loss. A just-under-limit entry must
3683 /// still succeed and replay intact.
3684 #[test]
3685 fn test_wal_append_rejects_oversize_entry_matching_replay_limit() {
3686 let temp_dir = TempDir::new().unwrap();
3687 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
3688 let path = wal.path().to_path_buf();
3689
3690 // Over-limit: serialized length > MAX_ENTRY_LENGTH (the payload alone
3691 // already exceeds the ceiling). Must be rejected and NOT acknowledged.
3692 let over = "x".repeat(MAX_ENTRY_LENGTH as usize + 1);
3693 let err = wal
3694 .append(&create_test_mutation(1, &over))
3695 .expect_err("over-limit append must be rejected, not acknowledged");
3696 match err {
3697 Error::Storage(msg) => assert!(
3698 msg.contains("MAX_ENTRY_LENGTH"),
3699 "error must cite the size ceiling, got: {msg}"
3700 ),
3701 other => panic!("expected Error::Storage, got {other:?}"),
3702 }
3703 assert_eq!(
3704 wal.size(),
3705 0,
3706 "a rejected append must not write or grow the WAL"
3707 );
3708
3709 // Just-under-limit: payload sized so the whole entry stays below the
3710 // ceiling; it must be accepted and survive a fresh reopen + replay.
3711 let under = "y".repeat(MAX_ENTRY_LENGTH as usize - 8192);
3712 wal.append(&create_test_mutation(2, &under)).unwrap();
3713 wal.sync().unwrap();
3714 drop(wal);
3715
3716 let wal = WriteAheadLog::open_existing(&path).unwrap();
3717 let report = wal.replay().unwrap();
3718 assert!(
3719 report.is_clean(),
3720 "just-under-limit entry must replay cleanly"
3721 );
3722 assert_eq!(report.mutations.len(), 1, "exactly the accepted entry");
3723 match &report.mutations[0].operations[0] {
3724 CellOperation::Write {
3725 value: Value::Text(name),
3726 ..
3727 } => assert_eq!(name.len(), under.len(), "recovered payload must match"),
3728 other => panic!("expected Write op, got {other:?}"),
3729 }
3730 }
3731
3732 /// Finding 2 (roborev r3): if any step of the reset sequence fails, the
3733 /// fail-closed guard must remain set so `append()` keeps rejecting writes.
3734 /// Injects a failure by removing the containing directory after the WAL fd is
3735 /// open: `flush`/`set_len`/`sync_all` still act on the open inode, but the
3736 /// final `sync_directory(parent)` opens the now-missing dir and errors. The
3737 /// old `.take()` cleared the guard up front, so an error left the WAL wrongly
3738 /// appendable; the guard must now survive the failure.
3739 #[cfg(unix)]
3740 #[test]
3741 fn test_wal_reset_failure_keeps_guard_set() {
3742 let temp_dir = TempDir::new().unwrap();
3743 let subdir = temp_dir.path().join("wal_sub");
3744 std::fs::create_dir(&subdir).unwrap();
3745
3746 // Write A + B inside the removable subdir, then corrupt B's payload in
3747 // place to produce a mid-stream corrupt tail (valid prefix ends at A).
3748 let (path, end_of_a, total) = {
3749 let mut wal = WriteAheadLog::create(&subdir).unwrap();
3750 wal.append(&create_test_mutation(1, "Alice")).unwrap();
3751 wal.sync().unwrap();
3752 let end_of_a = wal.size();
3753 wal.append(&create_test_mutation(2, "Bob")).unwrap();
3754 wal.sync().unwrap();
3755 let total = wal.size();
3756 let path = wal.path().to_path_buf();
3757 (path, end_of_a, total)
3758 };
3759 let b_first_payload = end_of_a + 8;
3760 assert!(
3761 b_first_payload < total,
3762 "test setup: B must have a payload byte"
3763 );
3764 {
3765 let mut bytes = std::fs::read(&path).unwrap();
3766 bytes[b_first_payload as usize] ^= 0xFF;
3767 std::fs::write(&path, &bytes).unwrap();
3768 }
3769
3770 let mut wal = WriteAheadLog::open_existing(&path).unwrap();
3771 assert!(
3772 wal.has_pending_corrupt_tail(),
3773 "mid-stream corruption must record a pending valid prefix"
3774 );
3775
3776 // Inject the reset-sequence failure: remove the dir the WAL lives in.
3777 std::fs::remove_dir_all(&subdir).unwrap();
3778
3779 let err = wal
3780 .reset_to_valid_prefix()
3781 .expect_err("reset must propagate the directory-sync failure");
3782 assert!(matches!(err, Error::Storage(_)), "expected Error::Storage");
3783 assert!(
3784 wal.has_pending_corrupt_tail(),
3785 "the guard MUST remain set when the reset sequence fails partway"
3786 );
3787
3788 // With the guard still set, append stays fail-closed.
3789 let append_err = wal
3790 .append(&create_test_mutation(3, "Carol"))
3791 .expect_err("append must remain rejected after a failed reset");
3792 match append_err {
3793 Error::Storage(msg) => assert!(
3794 msg.contains("reset_to_valid_prefix"),
3795 "error must still direct the caller to reset first, got: {msg}"
3796 ),
3797 other => panic!("expected Error::Storage, got {other:?}"),
3798 }
3799 }
3800
3801 /// Append A, B, C (all synced); return (path, end_of_A, end_of_B, total).
3802 fn write_three_entries(temp_dir: &TempDir) -> (PathBuf, u64, u64, u64) {
3803 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
3804
3805 wal.append(&create_test_mutation(1, "Alice")).unwrap();
3806 wal.sync().unwrap();
3807 let end_of_a = wal.size();
3808
3809 wal.append(&create_test_mutation(2, "Bob")).unwrap();
3810 wal.sync().unwrap();
3811 let end_of_b = wal.size();
3812
3813 wal.append(&create_test_mutation(3, "Carol")).unwrap();
3814 wal.sync().unwrap();
3815 let total = wal.size();
3816
3817 let path = wal.path().to_path_buf();
3818 drop(wal);
3819 (path, end_of_a, end_of_b, total)
3820 }
3821
3822 /// Issue #1391 (roborev r5, HIGH): a COMPLETE 8-byte header whose declared
3823 /// length is `<= MAX_ENTRY_LENGTH` but LARGER than the bytes remaining to EOF
3824 /// must be classified as CORRUPTION, not a torn tail. For an A,B,C log where
3825 /// B's length field is bit-flipped to a plausible-but-too-large value that
3826 /// runs past EOF, the OLD classifier read B's payload, hit EOF, called it a
3827 /// `TornTail`, trimmed the live WAL back to A, and reported a CLEAN recovery —
3828 /// silently discarding B AND C (an acknowledged, durable entry). That is the
3829 /// exact "silent lossy recovery reported as clean" #1391 exists to eliminate.
3830 ///
3831 /// A complete-header entry that cannot be satisfied by the remaining bytes is
3832 /// structurally INDISTINGUISHABLE from an interrupted final write using only
3833 /// the allowed no-heuristics facts (header completeness, declared-length vs
3834 /// remaining-bytes, physical-tail position); guessing which it is would be a
3835 /// byte-pattern heuristic. The safe, consistent rule therefore preserves the
3836 /// tail + reports it lossy rather than silent-trimming as clean.
3837 ///
3838 /// Verify: NOT reported clean; the B/C tail is preserved on disk (not trimmed)
3839 /// for the caller to copy aside; the report is lossy (`stopped_early`, a
3840 /// corrupt entry, `bytes_skipped > 0`); only A is recovered. Red-before: the
3841 /// OLD `TornTail` path trimmed to A + reported clean, so `is_clean()` was true
3842 /// and the file was truncated to `end_of_A`.
3843 #[test]
3844 fn test_wal_midstream_length_past_eof_is_lossy_not_clean() {
3845 let temp_dir = TempDir::new().unwrap();
3846 let (path, end_of_a, _end_of_b, total) = write_three_entries(&temp_dir);
3847
3848 // Remaining payload bytes after B's 8-byte header, all the way to EOF:
3849 // B's real payload + C's full framing. Bit-flip B's length UP to a value
3850 // that overshoots EOF (> remaining) yet stays within MAX_ENTRY_LENGTH, so
3851 // the payload read hits EOF (the misclassified-as-torn path) rather than
3852 // the oversize-length path.
3853 let remaining_after_b_header = total - (end_of_a + 8);
3854 let bogus_len = remaining_after_b_header + 4096;
3855 assert!(
3856 bogus_len <= MAX_ENTRY_LENGTH as u64,
3857 "test setup: bogus length must stay within MAX_ENTRY_LENGTH so it \
3858 exercises the past-EOF path, not the oversize path"
3859 );
3860 assert!(
3861 bogus_len > remaining_after_b_header,
3862 "test setup: bogus length must overshoot EOF"
3863 );
3864 {
3865 let mut bytes = std::fs::read(&path).unwrap();
3866 let len_field = (end_of_a as usize)..(end_of_a as usize + 4);
3867 bytes[len_field].copy_from_slice(&(bogus_len as u32).to_le_bytes());
3868 std::fs::write(&path, &bytes).unwrap();
3869 }
3870
3871 // Open: the corrupt segment (B + C) must be left INTACT on disk (evidence
3872 // preservation) with the valid prefix (end of A) recorded as pending, not
3873 // silently trimmed away.
3874 let wal = WriteAheadLog::open_existing(&path).unwrap();
3875 assert!(
3876 wal.has_pending_corrupt_tail(),
3877 "a complete-header entry with an unsatisfiable length must be preserved \
3878 pending (not silently trimmed as a torn tail)"
3879 );
3880 assert_eq!(
3881 std::fs::metadata(&path).unwrap().len(),
3882 total,
3883 "the B/C tail must remain on disk for evidence preservation"
3884 );
3885
3886 // Replay must NOT be clean: A recovered, B/C dropped and surfaced.
3887 let report = wal.replay().unwrap();
3888 assert!(
3889 !report.is_clean(),
3890 "a length-past-EOF middle entry must NOT report a clean recovery"
3891 );
3892 assert!(
3893 report.stopped_early,
3894 "replay must stop at the corrupt frame"
3895 );
3896 assert_eq!(report.corrupt_entries, 1, "the mis-sized entry is corrupt");
3897 assert!(
3898 report.bytes_skipped > 0,
3899 "the discarded B/C tail must be counted as skipped, not lost silently"
3900 );
3901 assert_eq!(
3902 report.mutations.len(),
3903 1,
3904 "only the valid prefix A survives"
3905 );
3906 match &report.mutations[0].operations[0] {
3907 CellOperation::Write {
3908 value: Value::Text(name),
3909 ..
3910 } => assert_eq!(name, "Alice", "the sole recovered entry must be A"),
3911 other => panic!("expected Write op, got {other:?}"),
3912 }
3913 }
3914
3915 /// Issue #1661: `replay_each` must be semantically identical to `replay()` —
3916 /// it must yield the SAME mutations in the SAME order and produce the SAME
3917 /// corruption metadata for a mixed WAL (valid entries plus a CRC-corrupt
3918 /// entry). This proves the streaming refactor changed only allocation
3919 /// behavior, not decode/skip/stop semantics.
3920 #[test]
3921 fn test_replay_each_matches_replay_for_mixed_wal() {
3922 let temp_dir = TempDir::new().unwrap();
3923
3924 // [A][B][C], all synced. Capture the end-of-A offset (start of B's frame).
3925 let (path, end_of_a) = {
3926 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
3927 wal.append(&create_test_mutation(1, "Alice")).unwrap();
3928 wal.sync().unwrap();
3929 let end_of_a = wal.size();
3930 wal.append(&create_test_mutation(2, "Bob")).unwrap();
3931 wal.sync().unwrap();
3932 wal.append(&create_test_mutation(3, "Carol")).unwrap();
3933 wal.sync().unwrap();
3934 let path = wal.path().to_path_buf();
3935 drop(wal);
3936 (path, end_of_a)
3937 };
3938
3939 // Corrupt B's CRC (4 bytes at end_of_a + 4) via a bit flip; B stays
3940 // structurally complete so this exercises the CRC-mismatch stop path.
3941 let mut file = OpenOptions::new()
3942 .read(true)
3943 .write(true)
3944 .open(&path)
3945 .unwrap();
3946 file.seek(SeekFrom::Start(end_of_a + 4)).unwrap();
3947 let mut crc = [0u8; 4];
3948 file.read_exact(&mut crc).unwrap();
3949 crc[0] ^= 0xFF;
3950 file.seek(SeekFrom::Start(end_of_a + 4)).unwrap();
3951 file.write_all(&crc).unwrap();
3952 file.sync_all().unwrap();
3953 drop(file);
3954
3955 let wal = WriteAheadLog::open_existing(&path).unwrap();
3956
3957 // Reference: the whole-log `replay()`.
3958 let report = wal.replay().unwrap();
3959
3960 // Streaming: collect the callback-delivered mutations.
3961 let mut streamed = Vec::new();
3962 let stream_report = wal
3963 .replay_each(|m| {
3964 streamed.push(m);
3965 Ok(())
3966 })
3967 .unwrap();
3968
3969 // Same mutations, same order (compare via bincode bytes — Mutation has no
3970 // PartialEq).
3971 let ser = |m: &Mutation| bincode::serialize(m).unwrap();
3972 let expected: Vec<Vec<u8>> = report.mutations.iter().map(ser).collect();
3973 let actual: Vec<Vec<u8>> = streamed.iter().map(ser).collect();
3974 assert_eq!(
3975 actual, expected,
3976 "replay_each must yield the same mutations in the same order as replay()"
3977 );
3978 // Only the valid prefix A survives the mid-stream corruption.
3979 assert_eq!(streamed.len(), 1, "only the valid prefix A is recovered");
3980
3981 // Same corruption metadata; `replay_each`'s report leaves mutations empty
3982 // (they were streamed to the callback), while `replay()` populates them.
3983 assert_eq!(stream_report.corrupt_entries, report.corrupt_entries);
3984 assert_eq!(stream_report.stopped_early, report.stopped_early);
3985 assert_eq!(stream_report.bytes_skipped, report.bytes_skipped);
3986 assert!(
3987 stream_report.stopped_early,
3988 "must stop at the corrupt entry"
3989 );
3990 assert_eq!(stream_report.corrupt_entries, 1);
3991 assert!(
3992 stream_report.mutations.is_empty(),
3993 "replay_each streams mutations to the callback, so its report Vec stays empty"
3994 );
3995 }
3996
3997 /// Issue #1661: a fully clean multi-entry WAL must stream every entry in
3998 /// order and report clean, matching `replay()`.
3999 #[test]
4000 fn test_replay_each_clean_multi_entry() {
4001 let temp_dir = TempDir::new().unwrap();
4002 let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
4003 wal.append(&create_test_mutation(1, "Alice")).unwrap();
4004 wal.append(&create_test_mutation(2, "Bob")).unwrap();
4005 wal.append(&create_test_mutation(3, "Carol")).unwrap();
4006 wal.sync().unwrap();
4007
4008 let report = wal.replay().unwrap();
4009 let mut streamed = Vec::new();
4010 let stream_report = wal
4011 .replay_each(|m| {
4012 streamed.push(m);
4013 Ok(())
4014 })
4015 .unwrap();
4016
4017 let ser = |m: &Mutation| bincode::serialize(m).unwrap();
4018 let expected: Vec<Vec<u8>> = report.mutations.iter().map(ser).collect();
4019 let actual: Vec<Vec<u8>> = streamed.iter().map(ser).collect();
4020 assert_eq!(actual, expected);
4021 assert_eq!(streamed.len(), 3);
4022 assert!(stream_report.is_clean());
4023 assert!(report.is_clean());
4024 }
4025
4026 #[test]
4027 fn test_sync_directory_invalid_path() {
4028 // Test that sync_directory fails for invalid paths
4029 let invalid_path = PathBuf::from("/nonexistent/path");
4030 let result = sync_directory(&invalid_path);
4031
4032 assert!(result.is_err());
4033 match result {
4034 Err(Error::Storage(_)) => {}
4035 _ => panic!("Expected Storage error"),
4036 }
4037 }
4038}