Skip to main content

chronix_engine/wal/
reader.rs

1//! WAL reader — sequential record reading with CRC verification.
2
3use std::fs::File;
4use std::io::{self, BufReader, Read};
5use std::path::{Path, PathBuf};
6
7use chronix_core::WalError;
8
9use crate::wal::{WAL_HEADER_SIZE, WAL_MAGIC, WAL_RECORD_HEADER_SIZE, WAL_VERSION};
10
11/// Maximum payload size per WAL record (256 MB).
12///
13/// The ceiling, not the bound. A record is also refused when its declared
14/// length runs past the end of the file, which is the check that actually
15/// matters on the devices this engine targets: 256 MB is a quarter of the
16/// gateway's RAM, and a single flipped bit in a length field is enough to ask
17/// for it. Bounding an allocation by a constant when the real bound — the
18/// bytes that exist — is one `metadata()` call away is the same mistake the
19/// block decoders made.
20const MAX_PAYLOAD_SIZE: u32 = 256 * 1024 * 1024;
21
22/// A single WAL record as read from disk.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct WalRecord {
25    /// Monotonically increasing sequence number.
26    pub sequence_no: u64,
27    /// Record type discriminant.
28    pub record_type: u8,
29    /// Payload serialisation version.
30    pub payload_version: u8,
31    /// Raw payload bytes.
32    pub payload: Vec<u8>,
33}
34
35/// Sequential WAL file reader with CRC verification.
36///
37/// Reads records from a single WAL file. Implements [`Iterator`] for
38/// convenient sequential consumption.
39pub struct WalReader {
40    reader: BufReader<File>,
41    path: PathBuf,
42    offset: u64,
43    finished: bool,
44    /// Size of the file when it was opened, used to bound payload allocation.
45    file_len: u64,
46}
47
48impl WalReader {
49    /// Open a WAL file for reading, validating the file header.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`WalError::InvalidHeader`] if magic bytes or version are wrong.
54    pub fn open(path: impl AsRef<Path>) -> Result<Self, WalError> {
55        let path = path.as_ref().to_path_buf();
56        let file = File::open(&path)?;
57        let file_len = file.metadata()?.len();
58        let mut reader = BufReader::new(file);
59
60        // Read and validate header
61        let mut magic = [0u8; 4];
62        reader
63            .read_exact(&mut magic)
64            .map_err(|e| WalError::InvalidHeader {
65                path: path.clone(),
66                detail: format!("Failed to read magic bytes: {e}"),
67            })?;
68
69        if &magic != WAL_MAGIC {
70            return Err(WalError::InvalidHeader {
71                path,
72                detail: format!("Invalid magic bytes: expected CXWL, got {magic:?}"),
73            });
74        }
75
76        let mut version_bytes = [0u8; 2];
77        reader
78            .read_exact(&mut version_bytes)
79            .map_err(|e| WalError::InvalidHeader {
80                path: path.clone(),
81                detail: format!("Failed to read version: {e}"),
82            })?;
83        let file_version = u16::from_le_bytes(version_bytes);
84
85        if let Some(detail) = crate::format::check(
86            "this write-ahead log",
87            u64::from(file_version),
88            u64::from(WAL_VERSION),
89        ) {
90            return Err(WalError::UnsupportedVersion { path, detail });
91        }
92
93        Ok(Self {
94            reader,
95            path,
96            offset: WAL_HEADER_SIZE as u64,
97            finished: false,
98            file_len,
99        })
100    }
101
102    /// Read the next record from the WAL file.
103    ///
104    /// Returns `Ok(None)` at end of file.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`WalError::Corruption`] if CRC verification fails.
109    /// Returns `Ok(None)` with a tracing warning if the record is truncated
110    /// (crash during write).
111    pub fn read_next(&mut self) -> Result<Option<WalRecord>, WalError> {
112        if self.finished {
113            return Ok(None);
114        }
115
116        // Read CRC (4 bytes)
117        let mut crc_bytes = [0u8; 4];
118        match self.reader.read_exact(&mut crc_bytes) {
119            Ok(()) => {}
120            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
121                self.finished = true;
122                return Ok(None); // Clean EOF
123            }
124            Err(e) => return Err(WalError::Io(e)),
125        }
126        let stored_crc = u32::from_le_bytes(crc_bytes);
127
128        // Read length (4 bytes)
129        let mut length_bytes = [0u8; 4];
130        if let Err(e) = self.reader.read_exact(&mut length_bytes) {
131            if e.kind() == io::ErrorKind::UnexpectedEof {
132                tracing::warn!(
133                    path = %self.path.display(),
134                    offset = self.offset,
135                    "Truncated WAL record (incomplete header), skipping"
136                );
137                self.finished = true;
138                return Ok(None);
139            }
140            return Err(WalError::Io(e));
141        }
142        let length = u32::from_le_bytes(length_bytes);
143
144        // Read sequence number (8 bytes)
145        let mut seq_bytes = [0u8; 8];
146        if let Err(e) = self.reader.read_exact(&mut seq_bytes) {
147            if e.kind() == io::ErrorKind::UnexpectedEof {
148                tracing::warn!(
149                    path = %self.path.display(),
150                    offset = self.offset,
151                    "Truncated WAL record (incomplete sequence), skipping"
152                );
153                self.finished = true;
154                return Ok(None);
155            }
156            return Err(WalError::Io(e));
157        }
158        let sequence_no = u64::from_le_bytes(seq_bytes);
159
160        // Read record_type and payload_version (2 bytes)
161        let mut type_ver = [0u8; 2];
162        if let Err(e) = self.reader.read_exact(&mut type_ver) {
163            if e.kind() == io::ErrorKind::UnexpectedEof {
164                tracing::warn!(
165                    path = %self.path.display(),
166                    offset = self.offset,
167                    "Truncated WAL record (incomplete type/version), skipping"
168                );
169                self.finished = true;
170                return Ok(None);
171            }
172            return Err(WalError::Io(e));
173        }
174        let (record_type, payload_version) = (type_ver[0], type_ver[1]);
175
176        // Guard against corrupted length fields causing huge allocations.
177        if length > MAX_PAYLOAD_SIZE {
178            self.finished = true;
179            return Err(WalError::Corruption {
180                offset: self.offset,
181                path: self.path.clone(),
182                detail: format!("payload length {length} exceeds maximum of {MAX_PAYLOAD_SIZE}"),
183            });
184        }
185
186        // The tighter bound: a payload cannot be longer than the bytes left in
187        // the file. Without this, a corrupt length reserves up to 256 MB before
188        // the read that would have failed — and the CRC that would have caught
189        // the corruption is verified *after* the payload is in memory, so it
190        // arrives too late to prevent the allocation.
191        let remaining = self
192            .file_len
193            .saturating_sub(self.offset + WAL_RECORD_HEADER_SIZE as u64);
194        if u64::from(length) > remaining {
195            tracing::warn!(
196                path = %self.path.display(),
197                offset = self.offset,
198                length,
199                remaining,
200                "Truncated WAL record (payload runs past end of file), skipping"
201            );
202            self.finished = true;
203            return Ok(None);
204        }
205
206        // Read payload
207        let mut payload = vec![0u8; length as usize];
208        if let Err(e) = self.reader.read_exact(&mut payload) {
209            if e.kind() == io::ErrorKind::UnexpectedEof {
210                tracing::warn!(
211                    path = %self.path.display(),
212                    offset = self.offset,
213                    sequence_no = sequence_no,
214                    "Truncated WAL record (incomplete payload), skipping"
215                );
216                self.finished = true;
217                return Ok(None);
218            }
219            return Err(WalError::Io(e));
220        }
221
222        // CRC covers [length, seq, record_type, payload_version, payload]
223        let computed_crc = {
224            let crc = crc32c::crc32c(&length_bytes);
225            let crc = crc32c::crc32c_append(crc, &seq_bytes);
226            let crc = crc32c::crc32c_append(crc, &[record_type, payload_version]);
227            crc32c::crc32c_append(crc, &payload)
228        };
229
230        if stored_crc != computed_crc {
231            self.offset += WAL_RECORD_HEADER_SIZE as u64 + u64::from(length);
232            return Err(WalError::Corruption {
233                offset: self.offset - WAL_RECORD_HEADER_SIZE as u64 - u64::from(length),
234                path: self.path.clone(),
235                detail: format!(
236                    "CRC mismatch: stored={stored_crc:#010x}, computed={computed_crc:#010x}"
237                ),
238            });
239        }
240
241        self.offset += WAL_RECORD_HEADER_SIZE as u64 + u64::from(length);
242
243        // Transparently decompress LZ4-compressed payloads.
244        let payload =
245            crate::wal::decompress_wal_payload(payload).map_err(|detail| WalError::Corruption {
246                offset: self.offset - WAL_RECORD_HEADER_SIZE as u64 - u64::from(length),
247                path: self.path.clone(),
248                detail,
249            })?;
250
251        Ok(Some(WalRecord {
252            sequence_no,
253            record_type,
254            payload_version,
255            payload,
256        }))
257    }
258}
259
260impl Iterator for WalReader {
261    type Item = Result<WalRecord, WalError>;
262
263    fn next(&mut self) -> Option<Self::Item> {
264        match self.read_next() {
265            Ok(Some(record)) => Some(Ok(record)),
266            Ok(None) => None,
267            Err(e) => Some(Err(e)),
268        }
269    }
270}
271
272/// Replay all WAL files in a directory in sequence order.
273///
274/// Reads all `wal_*.cxwl` files sorted by their starting sequence number,
275/// returning all records in order.
276///
277/// # Errors
278///
279/// Returns [`WalError`] if a WAL file cannot be opened (I/O error).
280/// Mid-file corruption is tolerated: corrupted records are skipped and
281/// replay continues with subsequent records in the same file (and any
282/// remaining files) to maximize data recovery.  Unrecoverable
283/// corruption (e.g. a garbled length field) terminates replay of that
284/// file only.
285///
286/// **Sequence monotonicity validation**: records whose sequence number is
287/// not strictly greater than the previous one are logged as warnings and
288/// skipped, preventing corrupted or duplicate entries from silently
289/// entering the replay stream.
290pub fn replay_all(dir: &Path) -> Result<Vec<WalRecord>, WalError> {
291    let files = crate::wal::writer::list_wal_files(dir)?;
292
293    let mut records = Vec::with_capacity(files.len().saturating_mul(128));
294    let mut last_seq: u64 = 0;
295
296    for (_, file_path) in &files {
297        let reader = WalReader::open(file_path)?;
298        for result in reader {
299            match result {
300                Ok(record) => {
301                    if record.sequence_no <= last_seq && last_seq > 0 {
302                        tracing::warn!(
303                            wal_path = %file_path.display(),
304                            expected_gt = last_seq,
305                            got = record.sequence_no,
306                            "WAL sequence monotonicity violation — skipping record"
307                        );
308                        continue;
309                    }
310                    last_seq = record.sequence_no;
311                    records.push(record);
312                }
313                Err(WalError::Corruption {
314                    offset,
315                    path,
316                    detail,
317                }) => {
318                    // Emit corruption metric so operators
319                    // can detect progressive storage degradation.
320                    metrics::counter!("chronix_wal_corrupted_records_total").increment(1);
321                    tracing::warn!(
322                        wal_path = %file_path.display(),
323                        corrupt_offset = offset,
324                        corrupt_file = %path.display(),
325                        detail = %detail,
326                        recovered = records.len(),
327                        "WAL corruption detected — skipping record, continuing recovery"
328                    );
329                    continue; // Try to recover subsequent records
330                }
331                Err(e) => return Err(e),
332            }
333        }
334    }
335
336    Ok(records)
337}
338
339/// Replay WAL records from `start_sequence` (exclusive) up to and including
340/// `target_sequence`.
341///
342/// Only records with `start_sequence < seq <= target_sequence` are returned.
343/// Useful for **Point-in-Time Recovery (PITR)** — given a base backup at
344/// `start_sequence`, this replays the delta needed to reach `target_sequence`.
345///
346/// WAL files whose entire range falls outside the window are skipped entirely
347/// for efficiency.
348///
349/// # Errors
350///
351/// Returns [`WalError`] if a WAL file cannot be opened. Corrupted records
352/// are skipped (same tolerance as [`replay_all`]).
353pub fn replay_range(
354    dir: &Path,
355    start_sequence: u64,
356    target_sequence: u64,
357) -> Result<Vec<WalRecord>, WalError> {
358    let files = crate::wal::writer::list_wal_files(dir)?;
359
360    let mut records = Vec::with_capacity(files.len().saturating_mul(64));
361    let mut last_seq: u64 = 0;
362
363    for (file_start, file_path) in &files {
364        // Skip files that are entirely before our start window.
365        // If the next file starts at N, this file contains seqs < N.
366        // We can't easily know each file's max seq without reading,
367        // but we can skip files whose start is > target_sequence.
368        if *file_start > target_sequence {
369            break; // Files are sorted — all subsequent files are past target
370        }
371
372        let reader = WalReader::open(file_path)?;
373        for result in reader {
374            match result {
375                Ok(record) => {
376                    // Past our target — done
377                    if record.sequence_no > target_sequence {
378                        return Ok(records);
379                    }
380
381                    // Before our window — skip
382                    if record.sequence_no <= start_sequence {
383                        continue;
384                    }
385
386                    // Monotonicity check
387                    if record.sequence_no <= last_seq && last_seq > 0 {
388                        tracing::warn!(
389                            wal_path = %file_path.display(),
390                            expected_gt = last_seq,
391                            got = record.sequence_no,
392                            "WAL sequence monotonicity violation — skipping record"
393                        );
394                        continue;
395                    }
396
397                    last_seq = record.sequence_no;
398                    records.push(record);
399                }
400                Err(WalError::Corruption {
401                    offset,
402                    path,
403                    detail,
404                }) => {
405                    tracing::warn!(
406                        wal_path = %file_path.display(),
407                        corrupt_offset = offset,
408                        corrupt_file = %path.display(),
409                        detail = %detail,
410                        "WAL corruption — skipping record during range replay"
411                    );
412                    continue;
413                }
414                Err(e) => return Err(e),
415            }
416        }
417    }
418
419    Ok(records)
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::wal::WalWriter;
426    use chronix_core::{FsyncPolicy, WalConfig};
427    use tempfile::TempDir;
428
429    fn test_config() -> WalConfig {
430        WalConfig {
431            fsync_policy: FsyncPolicy::PerBatch,
432            max_file_size: 32 * 1024 * 1024,
433            max_unflushed_wals: 10,
434            compress: true,
435            ..WalConfig::default()
436        }
437    }
438
439    #[test]
440    fn write_read_roundtrip() {
441        let dir = TempDir::new().unwrap();
442        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
443
444        writer.append(b"hello").unwrap();
445        writer.append(b"world").unwrap();
446        writer.sync().unwrap();
447
448        let records = replay_all(dir.path()).unwrap();
449        assert_eq!(records.len(), 2);
450        assert_eq!(records[0].sequence_no, 1);
451        assert_eq!(records[0].payload, b"hello");
452        assert_eq!(records[1].sequence_no, 2);
453        assert_eq!(records[1].payload, b"world");
454    }
455
456    #[test]
457    fn batch_write_read_roundtrip() {
458        let dir = TempDir::new().unwrap();
459        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
460
461        let payloads: Vec<&[u8]> = vec![b"alpha", b"beta", b"gamma"];
462        writer.append_batch(&payloads).unwrap();
463        writer.sync().unwrap();
464
465        // Batch is stored as a single atomic WAL record.
466        let records = replay_all(dir.path()).unwrap();
467        assert_eq!(records.len(), 1);
468        let sub = crate::wal::decode_batch_payload(&records[0].payload).unwrap();
469        assert_eq!(sub.len(), 3);
470        assert_eq!(sub[0], b"alpha");
471        assert_eq!(sub[1], b"beta");
472        assert_eq!(sub[2], b"gamma");
473    }
474
475    #[test]
476    fn read_across_rotated_files() {
477        let dir = TempDir::new().unwrap();
478        let config = WalConfig {
479            fsync_policy: FsyncPolicy::PerBatch,
480            max_file_size: 100, // Very small file to trigger rotation
481            max_unflushed_wals: 20,
482            compress: true,
483            ..WalConfig::default()
484        };
485        let writer = WalWriter::open(dir.path(), config).unwrap();
486
487        let payload = vec![42u8; 50];
488        for _ in 0..8 {
489            writer.append(&payload).unwrap();
490        }
491        writer.sync().unwrap();
492
493        let file_count = writer.file_count().unwrap();
494        assert!(file_count >= 2, "Expected rotation, got {file_count} files");
495
496        let records = replay_all(dir.path()).unwrap();
497        assert_eq!(records.len(), 8);
498        for (i, record) in records.iter().enumerate() {
499            assert_eq!(record.sequence_no, (i + 1) as u64);
500            assert_eq!(record.payload, payload);
501        }
502    }
503
504    #[test]
505    fn crc_corruption_detected() {
506        let dir = TempDir::new().unwrap();
507        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
508        writer.append(b"data").unwrap();
509        writer.sync().unwrap();
510        drop(writer);
511
512        // Find the WAL file and corrupt a byte
513        let mut wal_path = None;
514        for entry in std::fs::read_dir(dir.path()).unwrap() {
515            let path = entry.unwrap().path();
516            if path.extension().is_some_and(|e| e == "cxwl") {
517                wal_path = Some(path);
518                break;
519            }
520        }
521        let wal_path = wal_path.unwrap();
522        let mut data = std::fs::read(&wal_path).unwrap();
523        // Corrupt the payload area (after header + record header)
524        let corrupt_offset = WAL_HEADER_SIZE + WAL_RECORD_HEADER_SIZE + 1;
525        if corrupt_offset < data.len() {
526            data[corrupt_offset] ^= 0xFF;
527            std::fs::write(&wal_path, &data).unwrap();
528        }
529
530        let result = replay_all(dir.path());
531        // After the fix, replay_all tolerates corruption by skipping the
532        // rest of the corrupted file and continuing with subsequent files.
533        // With only one file and one record (which is corrupt), we get 0
534        // records back instead of an error.
535        assert!(result.is_ok(), "Expected Ok, got: {result:?}");
536        let records = result.unwrap();
537        assert_eq!(records.len(), 0, "Corrupt record should be skipped");
538    }
539
540    /// The documented guarantee is that a corrupt record is skipped and
541    /// replay continues **within the same file**. Existing coverage only
542    /// exercised a corrupt first record and corruption across files, so the
543    /// in-file resync path was untested.
544    #[test]
545    fn corruption_mid_file_recovers_later_records_in_the_same_file() {
546        let dir = TempDir::new().unwrap();
547        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
548        for i in 0..5u8 {
549            writer.append(&[b'r', i]).unwrap();
550        }
551        writer.sync().unwrap();
552        drop(writer);
553
554        let wal_path = std::fs::read_dir(dir.path())
555            .unwrap()
556            .filter_map(|e| {
557                let p = e.unwrap().path();
558                (p.extension().is_some_and(|x| x == "cxwl")).then_some(p)
559            })
560            .next()
561            .expect("wal file");
562
563        let clean = replay_all(dir.path()).unwrap();
564        assert_eq!(clean.len(), 5, "baseline replay should see all records");
565
566        // Corrupt the payload of the *third* record, leaving its length
567        // field intact so the reader can resync.
568        let record_stride = WAL_RECORD_HEADER_SIZE + 2;
569        let third_payload = WAL_HEADER_SIZE + 2 * record_stride + WAL_RECORD_HEADER_SIZE;
570        let mut data = std::fs::read(&wal_path).unwrap();
571        assert!(third_payload < data.len(), "layout assumption broken");
572        data[third_payload] ^= 0xFF;
573        std::fs::write(&wal_path, &data).unwrap();
574
575        let recovered = replay_all(dir.path()).expect("replay should tolerate corruption");
576        assert_eq!(
577            recovered.len(),
578            4,
579            "expected the corrupt record to be skipped and the rest recovered, got {}",
580            recovered.len()
581        );
582        // Specifically: the records *after* the corruption must survive.
583        let seqs: Vec<u64> = recovered.iter().map(|r| r.sequence_no).collect();
584        assert!(
585            seqs.len() == 4 && seqs.windows(2).all(|w| w[0] < w[1]),
586            "recovered sequences not strictly increasing: {seqs:?}"
587        );
588    }
589
590    /// Verify that corruption in one WAL file does not prevent recovery
591    /// of records from subsequent WAL files.
592    #[test]
593    fn corruption_does_not_lose_subsequent_files() {
594        let dir = TempDir::new().unwrap();
595        let config = WalConfig {
596            fsync_policy: FsyncPolicy::PerBatch,
597            max_file_size: 64, // small files → force rotation
598            max_unflushed_wals: 100,
599            compress: true,
600            ..WalConfig::default()
601        };
602        let writer = WalWriter::open(dir.path(), config).unwrap();
603
604        // Write enough records to force multiple WAL files
605        for i in 0..10 {
606            let payload = format!("record_{i}");
607            writer.append(payload.as_bytes()).unwrap();
608        }
609        writer.sync().unwrap();
610        drop(writer);
611
612        // Find all WAL files and corrupt the first one
613        let mut wal_files: Vec<std::path::PathBuf> = std::fs::read_dir(dir.path())
614            .unwrap()
615            .filter_map(|e| {
616                let p = e.unwrap().path();
617                if p.extension().is_some_and(|e| e == "cxwl") {
618                    Some(p)
619                } else {
620                    None
621                }
622            })
623            .collect();
624        wal_files.sort();
625
626        assert!(
627            wal_files.len() >= 2,
628            "Need multiple WAL files for this test"
629        );
630
631        // Corrupt the first WAL file
632        let mut data = std::fs::read(&wal_files[0]).unwrap();
633        let corrupt_offset = WAL_HEADER_SIZE + WAL_RECORD_HEADER_SIZE + 1;
634        if corrupt_offset < data.len() {
635            data[corrupt_offset] ^= 0xFF;
636            std::fs::write(&wal_files[0], &data).unwrap();
637        }
638
639        // Replay should still recover records from subsequent files
640        let records = replay_all(dir.path()).unwrap();
641        assert!(
642            !records.is_empty(),
643            "Should recover records from non-corrupt files"
644        );
645        // We wrote 10 records; at least 1 was in the corrupt file,
646        // so we should get fewer than 10 back, but more than 0.
647        assert!(
648            records.len() < 10,
649            "Should have skipped records in corrupt file"
650        );
651    }
652
653    #[test]
654    fn truncated_tail_tolerance() {
655        let dir = TempDir::new().unwrap();
656        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
657
658        writer.append(b"complete record").unwrap();
659        writer.append(b"another complete record").unwrap();
660        writer.sync().unwrap();
661        drop(writer);
662
663        // Find the WAL file and truncate it in the middle of where a 3rd record would be
664        let mut wal_path = None;
665        for entry in std::fs::read_dir(dir.path()).unwrap() {
666            let path = entry.unwrap().path();
667            if path.extension().is_some_and(|e| e == "cxwl") {
668                wal_path = Some(path);
669                break;
670            }
671        }
672        let wal_path = wal_path.unwrap();
673        let mut data = std::fs::read(&wal_path).unwrap();
674        // Append a partial record header (simulate crash mid-write)
675        data.extend_from_slice(&[0u8; 5]); // Incomplete header
676        std::fs::write(&wal_path, &data).unwrap();
677
678        // Should recover the two complete records and skip the truncated tail
679        let records = replay_all(dir.path()).unwrap();
680        assert_eq!(records.len(), 2);
681        assert_eq!(records[0].payload, b"complete record");
682        assert_eq!(records[1].payload, b"another complete record");
683    }
684
685    #[test]
686    fn crash_recovery_per_batch() {
687        let dir = TempDir::new().unwrap();
688        let dir_path = dir.path().to_path_buf();
689
690        // Write and sync some records, then "crash" (drop without close)
691        {
692            let writer = WalWriter::open(&dir_path, test_config()).unwrap();
693            let payloads: Vec<&[u8]> = vec![b"one", b"two", b"three"];
694            writer.append_batch(&payloads).unwrap();
695            // PerBatch: sync happens in append_batch
696            // Drop writer without explicit close — simulates crash
697        }
698
699        // Replay — the atomic batch record should be recoverable.
700        let records = replay_all(&dir_path).unwrap();
701        assert_eq!(records.len(), 1);
702        let sub = crate::wal::decode_batch_payload(&records[0].payload).unwrap();
703        assert_eq!(sub.len(), 3);
704        assert_eq!(sub[0], b"one");
705        assert_eq!(sub[1], b"two");
706        assert_eq!(sub[2], b"three");
707    }
708
709    #[test]
710    fn crash_recovery_per_write() {
711        let dir = TempDir::new().unwrap();
712        let dir_path = dir.path().to_path_buf();
713
714        {
715            let config = WalConfig {
716                fsync_policy: FsyncPolicy::PerWrite,
717                max_file_size: 32 * 1024 * 1024,
718                max_unflushed_wals: 10,
719                compress: true,
720                ..WalConfig::default()
721            };
722            let writer = WalWriter::open(&dir_path, config).unwrap();
723            writer.append(b"A").unwrap();
724            writer.append(b"B").unwrap();
725            writer.append(b"C").unwrap();
726            // Drop = crash simulation
727        }
728
729        let records = replay_all(&dir_path).unwrap();
730        assert_eq!(records.len(), 3);
731    }
732
733    #[test]
734    fn crash_recovery_across_rotation() {
735        let dir = TempDir::new().unwrap();
736        let dir_path = dir.path().to_path_buf();
737
738        {
739            let config = WalConfig {
740                fsync_policy: FsyncPolicy::PerBatch,
741                max_file_size: 80,
742                max_unflushed_wals: 20,
743                compress: true,
744                ..WalConfig::default()
745            };
746            let writer = WalWriter::open(&dir_path, config).unwrap();
747            let payload = vec![0u8; 40];
748            for _ in 0..6 {
749                writer.append(&payload).unwrap();
750            }
751            // Crash simulation
752        }
753
754        let records = replay_all(&dir_path).unwrap();
755        assert_eq!(records.len(), 6);
756        for (i, record) in records.iter().enumerate() {
757            assert_eq!(record.sequence_no, (i + 1) as u64);
758        }
759    }
760
761    #[test]
762    fn invalid_header_magic() {
763        let dir = TempDir::new().unwrap();
764        let path = dir.path().join("bad.cxwl");
765        std::fs::write(&path, b"BADM\x01\x00").unwrap();
766
767        let result = WalReader::open(&path);
768        assert!(matches!(result, Err(WalError::InvalidHeader { .. })));
769    }
770
771    /// A log from another format generation is its own error, and says what
772    /// to do about it.
773    ///
774    /// This used to assert `InvalidHeader`, which lumped "the magic bytes are
775    /// wrong" together with "this log is from a different version of
776    /// chronix" — two facts with different remedies. The header here is
777    /// perfectly well formed. See `crate::format`.
778    #[test]
779    fn a_log_from_another_generation_is_not_an_invalid_header() {
780        let dir = TempDir::new().unwrap();
781        let path = dir.path().join("bad.cxwl");
782        let mut data = Vec::new();
783        data.extend_from_slice(WAL_MAGIC);
784        data.extend_from_slice(&99u16.to_le_bytes());
785        std::fs::write(&path, &data).unwrap();
786
787        let err = match WalReader::open(&path) {
788            Err(e) => e,
789            Ok(_) => panic!("a foreign format generation must be refused"),
790        };
791        assert!(
792            matches!(err, WalError::UnsupportedVersion { .. }),
793            "a foreign format generation must not be reported as a malformed \
794             header or as corruption, which send an operator looking for a \
795             failing disk: got {err}"
796        );
797        let msg = err.to_string();
798        assert!(
799            msg.contains("99"),
800            "the message names what was found: {msg}"
801        );
802        assert!(
803            msg.contains("re-ingest") || msg.contains("new data directory"),
804            "the message names the remedy: {msg}"
805        );
806    }
807
808    #[test]
809    fn replay_empty_directory() {
810        let dir = TempDir::new().unwrap();
811        let records = replay_all(dir.path()).unwrap();
812        assert!(records.is_empty());
813    }
814
815    #[test]
816    fn replay_nonexistent_directory() {
817        let records = replay_all(Path::new("/nonexistent/path/surely")).unwrap();
818        assert!(records.is_empty());
819    }
820
821    #[test]
822    fn large_payload() {
823        let dir = TempDir::new().unwrap();
824        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
825
826        let payload = vec![0xAB; 1_000_000]; // 1 MB
827        writer.append(&payload).unwrap();
828        writer.sync().unwrap();
829
830        let records = replay_all(dir.path()).unwrap();
831        assert_eq!(records.len(), 1);
832        assert_eq!(records[0].payload.len(), 1_000_000);
833        assert!(records[0].payload.iter().all(|&b| b == 0xAB));
834    }
835
836    #[test]
837    fn sequence_continuity() {
838        let dir = TempDir::new().unwrap();
839        let config = WalConfig {
840            fsync_policy: FsyncPolicy::PerBatch,
841            max_file_size: 100,
842            max_unflushed_wals: 20,
843            compress: true,
844            ..WalConfig::default()
845        };
846        let writer = WalWriter::open(dir.path(), config).unwrap();
847
848        let payload = vec![0u8; 40];
849        for _ in 0..20 {
850            writer.append(&payload).unwrap();
851        }
852        writer.sync().unwrap();
853
854        let records = replay_all(dir.path()).unwrap();
855        for (i, record) in records.iter().enumerate() {
856            assert_eq!(
857                record.sequence_no,
858                (i + 1) as u64,
859                "Sequence gap at index {i}"
860            );
861        }
862    }
863
864    #[test]
865    fn empty_wal_file_header_only() {
866        let dir = TempDir::new().unwrap();
867        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
868        // Drop writer immediately — file has only the 6-byte header.
869        drop(writer);
870
871        let records = replay_all(dir.path()).unwrap();
872        assert!(records.is_empty());
873    }
874
875    #[test]
876    fn empty_payload_roundtrip() {
877        let dir = TempDir::new().unwrap();
878        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
879        writer.append(b"").unwrap();
880        writer.sync().unwrap();
881
882        let records = replay_all(dir.path()).unwrap();
883        assert_eq!(records.len(), 1);
884        assert!(records[0].payload.is_empty());
885    }
886
887    #[test]
888    fn iterator_error_propagation() {
889        let dir = TempDir::new().unwrap();
890        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
891        writer.append(b"good").unwrap();
892        writer.sync().unwrap();
893        drop(writer);
894
895        // Corrupt the CRC of the first record
896        let mut wal_path = None;
897        for entry in std::fs::read_dir(dir.path()).unwrap() {
898            let path = entry.unwrap().path();
899            if path.extension().is_some_and(|e| e == "cxwl") {
900                wal_path = Some(path);
901                break;
902            }
903        }
904        let wal_path = wal_path.unwrap();
905        let mut data = std::fs::read(&wal_path).unwrap();
906        // Flip a bit in the CRC field (bytes 6..10)
907        data[6] ^= 0xFF;
908        std::fs::write(&wal_path, &data).unwrap();
909
910        let reader = WalReader::open(&wal_path).unwrap();
911        let results: Vec<_> = reader.collect();
912        assert_eq!(results.len(), 1);
913        assert!(results[0].is_err());
914    }
915
916    #[test]
917    fn single_element_batch() {
918        let dir = TempDir::new().unwrap();
919        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
920        let payloads: Vec<&[u8]> = vec![b"only_one"];
921        let last_seq = writer.append_batch(&payloads).unwrap();
922        assert_eq!(last_seq, 1);
923        writer.sync().unwrap();
924
925        let records = replay_all(dir.path()).unwrap();
926        assert_eq!(records.len(), 1);
927        // Single-element batch is still framed as a batch record.
928        let sub = crate::wal::decode_batch_payload(&records[0].payload).unwrap();
929        assert_eq!(sub.len(), 1);
930        assert_eq!(sub[0], b"only_one");
931    }
932
933    #[test]
934    fn replay_range_basic() {
935        let dir = TempDir::new().unwrap();
936        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
937
938        for i in 0..10 {
939            writer.append(format!("rec_{i}").as_bytes()).unwrap();
940        }
941        writer.sync().unwrap();
942
943        // Replay records 4..=7 (seqs 4, 5, 6, 7)
944        let records = replay_range(dir.path(), 3, 7).unwrap();
945        assert_eq!(records.len(), 4);
946        assert_eq!(records[0].sequence_no, 4);
947        assert_eq!(records[3].sequence_no, 7);
948    }
949
950    #[test]
951    fn replay_range_from_start() {
952        let dir = TempDir::new().unwrap();
953        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
954
955        for i in 0..5 {
956            writer.append(format!("rec_{i}").as_bytes()).unwrap();
957        }
958        writer.sync().unwrap();
959
960        // Replay from beginning (start=0) up to seq 3
961        let records = replay_range(dir.path(), 0, 3).unwrap();
962        assert_eq!(records.len(), 3);
963        assert_eq!(records[0].sequence_no, 1);
964        assert_eq!(records[2].sequence_no, 3);
965    }
966
967    #[test]
968    fn replay_range_full() {
969        let dir = TempDir::new().unwrap();
970        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
971
972        for i in 0..5 {
973            writer.append(format!("rec_{i}").as_bytes()).unwrap();
974        }
975        writer.sync().unwrap();
976
977        // Replay everything
978        let records = replay_range(dir.path(), 0, u64::MAX).unwrap();
979        assert_eq!(records.len(), 5);
980    }
981
982    #[test]
983    fn replay_range_empty_window() {
984        let dir = TempDir::new().unwrap();
985        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
986
987        for i in 0..5 {
988            writer.append(format!("rec_{i}").as_bytes()).unwrap();
989        }
990        writer.sync().unwrap();
991
992        // Window past all records
993        let records = replay_range(dir.path(), 10, 20).unwrap();
994        assert!(records.is_empty());
995    }
996
997    #[test]
998    fn replay_range_across_rotated_files() {
999        let dir = TempDir::new().unwrap();
1000        let config = WalConfig {
1001            fsync_policy: FsyncPolicy::PerBatch,
1002            max_file_size: 100,
1003            max_unflushed_wals: 20,
1004            compress: true,
1005            ..WalConfig::default()
1006        };
1007        let writer = WalWriter::open(dir.path(), config).unwrap();
1008
1009        let payload = vec![42u8; 50];
1010        for _ in 0..8 {
1011            writer.append(&payload).unwrap();
1012        }
1013        writer.sync().unwrap();
1014
1015        let file_count = writer.file_count().unwrap();
1016        assert!(file_count >= 2, "Expected rotation, got {file_count} files");
1017
1018        // Replay only middle records
1019        let records = replay_range(dir.path(), 2, 6).unwrap();
1020        assert_eq!(records.len(), 4);
1021        assert_eq!(records[0].sequence_no, 3);
1022        assert_eq!(records[3].sequence_no, 6);
1023    }
1024}
1025
1026#[cfg(test)]
1027mod proptests {
1028    use super::*;
1029    use crate::wal::WalWriter;
1030    use chronix_core::{FsyncPolicy, WalConfig};
1031    use proptest::prelude::*;
1032    use tempfile::TempDir;
1033
1034    proptest! {
1035        #[test]
1036        fn arbitrary_payloads_roundtrip(
1037            payloads in proptest::collection::vec(
1038                proptest::collection::vec(any::<u8>(), 0..512),
1039                1..20
1040            )
1041        ) {
1042            let dir = TempDir::new().unwrap();
1043            let config = WalConfig {
1044                fsync_policy: FsyncPolicy::PerBatch,
1045                max_file_size: 32 * 1024 * 1024,
1046                max_unflushed_wals: 100,
1047                compress: true,
1048                ..WalConfig::default()
1049            };
1050            let writer = WalWriter::open(dir.path(), config).unwrap();
1051
1052            for payload in &payloads {
1053                writer.append(payload).unwrap();
1054            }
1055            writer.sync().unwrap();
1056
1057            let records = replay_all(dir.path()).unwrap();
1058            prop_assert_eq!(records.len(), payloads.len());
1059            for (i, (record, payload)) in records.iter().zip(payloads.iter()).enumerate() {
1060                prop_assert_eq!(record.sequence_no, (i + 1) as u64);
1061                prop_assert_eq!(&record.payload, payload);
1062            }
1063        }
1064
1065        #[test]
1066        fn batch_arbitrary_roundtrip(
1067            payloads in proptest::collection::vec(
1068                proptest::collection::vec(any::<u8>(), 1..256),
1069                1..10
1070            )
1071        ) {
1072            let dir = TempDir::new().unwrap();
1073            let config = WalConfig {
1074                fsync_policy: FsyncPolicy::PerBatch,
1075                max_file_size: 32 * 1024 * 1024,
1076                max_unflushed_wals: 100,
1077                compress: true,
1078                ..WalConfig::default()
1079            };
1080            let writer = WalWriter::open(dir.path(), config).unwrap();
1081
1082            let refs: Vec<&[u8]> = payloads.iter().map(std::vec::Vec::as_slice).collect();
1083            writer.append_batch(&refs).unwrap();
1084            writer.sync().unwrap();
1085
1086            // Batch is stored as a single atomic WAL record.
1087            let records = replay_all(dir.path()).unwrap();
1088            prop_assert_eq!(records.len(), 1);
1089            let decoded = crate::wal::decode_batch_payload(&records[0].payload)
1090                .expect("batch decode should succeed");
1091            prop_assert_eq!(decoded.len(), payloads.len());
1092            for (sub, payload) in decoded.iter().zip(payloads.iter()) {
1093                prop_assert_eq!(sub, payload);
1094            }
1095        }
1096    }
1097}