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