Skip to main content

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(&current).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            &current_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            &current_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            &current_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            &current_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}