1use 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
11const MAX_PAYLOAD_SIZE: u32 = 256 * 1024 * 1024;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct WalRecord {
25 pub sequence_no: u64,
27 pub record_type: u8,
29 pub payload_version: u8,
31 pub payload: Vec<u8>,
33}
34
35pub struct WalReader {
40 reader: BufReader<File>,
41 path: PathBuf,
42 offset: u64,
43 finished: bool,
44 file_len: u64,
46}
47
48impl WalReader {
49 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 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 pub fn read_next(&mut self) -> Result<Option<WalRecord>, WalError> {
111 if self.finished {
112 return Ok(None);
113 }
114
115 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); }
123 Err(e) => return Err(WalError::Io(e)),
124 }
125 let stored_crc = u32::from_le_bytes(crc_bytes);
126
127 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 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 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 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 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 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 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 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
271pub 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 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; }
330 Err(e) => return Err(e),
331 }
332 }
333 }
334
335 Ok(records)
336}
337
338pub 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 if *file_start > target_sequence {
368 break; }
370
371 let reader = WalReader::open(file_path)?;
372 for result in reader {
373 match result {
374 Ok(record) => {
375 if record.sequence_no > target_sequence {
377 return Ok(records);
378 }
379
380 if record.sequence_no <= start_sequence {
382 continue;
383 }
384
385 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 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, 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 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 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 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 #[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 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 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 #[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, max_unflushed_wals: 100,
598 compress: true,
599 ..WalConfig::default()
600 };
601 let writer = WalWriter::open(dir.path(), config).unwrap();
602
603 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 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 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 let records = replay_all(dir.path()).unwrap();
640 assert!(
641 !records.is_empty(),
642 "Should recover records from non-corrupt files"
643 );
644 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 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 data.extend_from_slice(&[0u8; 5]); std::fs::write(&wal_path, &data).unwrap();
676
677 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 {
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 }
697
698 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 }
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 }
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()); 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]; 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);
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 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 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 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 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 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 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 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 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 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}