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 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 pub fn read_next(&mut self) -> Result<Option<WalRecord>, WalError> {
112 if self.finished {
113 return Ok(None);
114 }
115
116 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); }
124 Err(e) => return Err(WalError::Io(e)),
125 }
126 let stored_crc = u32::from_le_bytes(crc_bytes);
127
128 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 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 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 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 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 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 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 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
272pub 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 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; }
331 Err(e) => return Err(e),
332 }
333 }
334 }
335
336 Ok(records)
337}
338
339pub 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 if *file_start > target_sequence {
369 break; }
371
372 let reader = WalReader::open(file_path)?;
373 for result in reader {
374 match result {
375 Ok(record) => {
376 if record.sequence_no > target_sequence {
378 return Ok(records);
379 }
380
381 if record.sequence_no <= start_sequence {
383 continue;
384 }
385
386 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 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, 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 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 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 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 #[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 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 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 #[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, max_unflushed_wals: 100,
599 compress: true,
600 ..WalConfig::default()
601 };
602 let writer = WalWriter::open(dir.path(), config).unwrap();
603
604 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 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 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 let records = replay_all(dir.path()).unwrap();
641 assert!(
642 !records.is_empty(),
643 "Should recover records from non-corrupt files"
644 );
645 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 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 data.extend_from_slice(&[0u8; 5]); std::fs::write(&wal_path, &data).unwrap();
677
678 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 {
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 }
698
699 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 }
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 }
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 #[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]; 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);
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 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 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 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 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 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 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 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 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 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}