1use std::fs::File;
26use std::io::{Read, Seek, SeekFrom};
27use std::path::Path;
28
29use crate::crypto::KeyRing;
30use crate::error::{Result, WalError};
31use crate::preamble::{SegmentPreamble, WAL_PREAMBLE_MAGIC, read_leading_preamble};
32use crate::record::foreign_version::read_declared_payload;
33use crate::record::{
34 HEADER_SIZE, RecordHeader, RecordType, WalRecord, is_format_gap, may_be_format_gap,
35};
36use crate::segment::SegmentDecryptor;
37
38fn checked_offset_add(offset: u64, len: usize) -> Result<u64> {
39 let len = u64::try_from(len).map_err(|_| {
40 WalError::Io(std::io::Error::new(
41 std::io::ErrorKind::InvalidData,
42 "WAL read length does not fit u64",
43 ))
44 })?;
45 offset.checked_add(len).ok_or_else(|| {
46 WalError::Io(std::io::Error::new(
47 std::io::ErrorKind::InvalidData,
48 "WAL read offset overflow",
49 ))
50 })
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum StopReason {
61 Eof,
63
64 Corruption { offset: u64 },
68}
69
70pub struct WalReader {
72 file: File,
73 offset: u64,
74 records_start: u64,
78 segment_preamble: Option<SegmentPreamble>,
81 double_write: Option<crate::double_write::DoubleWriteBuffer>,
83 stop_reason: Option<StopReason>,
85 committed_end: u64,
87 decryptor: Option<SegmentDecryptor>,
90}
91
92impl WalReader {
93 pub fn open(path: &Path, keys: Option<&KeyRing>) -> Result<Self> {
102 let mut reader = Self::open_raw(path)?;
103 reader.decryptor = Some(SegmentDecryptor::new(
104 reader.segment_preamble.as_ref(),
105 keys,
106 ));
107 Ok(reader)
108 }
109
110 pub fn open_raw(path: &Path) -> Result<Self> {
126 let mut file = File::open(path)?;
127 let dwb_path = path.with_extension("dwb");
128 let double_write = if dwb_path.exists() {
129 crate::double_write::DoubleWriteBuffer::open(
130 &dwb_path,
131 crate::double_write::DwbMode::Buffered,
132 )
133 .ok()
134 } else {
135 None
136 };
137
138 let (segment_preamble, start_offset) =
139 read_leading_preamble(&mut file, &WAL_PREAMBLE_MAGIC)?;
140
141 Ok(Self {
142 file,
143 offset: start_offset,
144 records_start: start_offset,
145 segment_preamble,
146 double_write,
147 stop_reason: None,
148 committed_end: start_offset,
149 decryptor: None,
150 })
151 }
152
153 fn deliver(&self, record: WalRecord) -> Result<Option<WalRecord>> {
156 match &self.decryptor {
157 Some(decryptor) => decryptor.decrypt_record(record).map(Some),
158 None => Ok(Some(record)),
159 }
160 }
161
162 pub fn committed_end(&self) -> u64 {
169 self.committed_end
170 }
171
172 pub fn stop_reason(&self) -> Option<StopReason> {
179 self.stop_reason
180 }
181
182 fn stop(&mut self, reason: StopReason) -> Result<Option<WalRecord>> {
183 self.stop_reason = Some(reason);
184 Ok(None)
185 }
186
187 pub fn segment_preamble(&self) -> Option<&SegmentPreamble> {
191 self.segment_preamble.as_ref()
192 }
193
194 pub fn next_record(&mut self) -> Result<Option<WalRecord>> {
202 loop {
203 let record_offset = self.offset;
205 let mut header_buf = [0u8; HEADER_SIZE];
206 match self.read_exact(&mut header_buf) {
207 Ok(()) => {}
208 Err(WalError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
209 let reason = if record_offset == self.file_len()? {
212 StopReason::Eof
213 } else {
214 StopReason::Corruption {
215 offset: record_offset,
216 }
217 };
218 return self.stop(reason);
219 }
220 Err(e) => return Err(e),
221 }
222
223 let header = RecordHeader::from_bytes(&header_buf);
224
225 let header_offset = self
229 .offset
230 .checked_sub(u64::try_from(HEADER_SIZE).map_err(|_| {
231 WalError::Io(std::io::Error::new(
232 std::io::ErrorKind::InvalidData,
233 "WAL header size does not fit u64",
234 ))
235 })?)
236 .ok_or_else(|| {
237 WalError::Io(std::io::Error::new(
238 std::io::ErrorKind::InvalidData,
239 "WAL header offset underflow",
240 ))
241 })?;
242 match header.validate(header_offset) {
243 Ok(()) => {}
244 Err(error @ WalError::PayloadTooLarge { .. }) => return Err(error),
245 Err(error @ WalError::UnsupportedVersion { .. })
249 if may_be_format_gap(&header, header_offset == self.records_start) =>
250 {
251 let payload = read_declared_payload(&mut self.file, &header, self.offset)?;
252 if is_format_gap(&header, payload.as_deref()) {
253 return Err(error);
254 }
255 return self.stop(StopReason::Corruption {
256 offset: header_offset,
257 });
258 }
259 Err(_) => {
260 return self.stop(StopReason::Corruption {
261 offset: header_offset,
262 });
263 }
264 }
265
266 let payload_len =
268 usize::try_from(header.payload_len).map_err(|_| WalError::PayloadTooLarge {
269 size: usize::MAX,
270 max: crate::record::MAX_WAL_PAYLOAD_SIZE,
271 })?;
272 let mut payload = vec![0u8; payload_len];
273 if !payload.is_empty() {
274 match self.read_exact(&mut payload) {
275 Ok(()) => {}
276 Err(WalError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
277 return self.stop(StopReason::Corruption {
279 offset: header_offset,
280 });
281 }
282 Err(e) => return Err(e),
283 }
284 }
285
286 let record = WalRecord { header, payload };
287
288 if record.verify_checksum().is_err() {
290 if let Some(recovered) = self.repair_torn_record(&header, header_offset)? {
291 return self.deliver(recovered);
292 }
293 return self.stop(StopReason::Corruption {
294 offset: header_offset,
295 });
296 }
297
298 self.committed_end = self.offset;
301
302 let logical_type = record.logical_record_type();
304 let Some(kind) = RecordType::from_raw(logical_type) else {
305 if RecordType::is_required(logical_type) {
306 return Err(WalError::UnknownRequiredRecordType {
307 record_type: header.record_type,
308 lsn: header.lsn,
309 });
310 }
311 continue;
313 };
314
315 if kind == RecordType::Noop {
317 continue;
318 }
319
320 return self.deliver(record);
321 }
322 }
323
324 fn repair_torn_record(
332 &mut self,
333 header: &RecordHeader,
334 header_offset: u64,
335 ) -> Result<Option<WalRecord>> {
336 let Some(dwb) = &mut self.double_write else {
337 return Ok(None);
338 };
339 let Ok(Some(recovered)) = dwb.recover_record(header.lsn) else {
340 return Ok(None);
341 };
342 tracing::info!(
343 lsn = header.lsn,
344 "recovered torn write from double-write buffer"
345 );
346 let resume = checked_offset_add(
347 checked_offset_add(header_offset, HEADER_SIZE)?,
348 recovered.payload.len(),
349 )?;
350 self.file.seek(SeekFrom::Start(resume))?;
351 self.offset = resume;
352 self.committed_end = resume;
353 Ok(Some(recovered))
354 }
355
356 pub fn records(self) -> WalRecordIter {
358 WalRecordIter { reader: self }
359 }
360
361 pub fn offset(&self) -> u64 {
363 self.offset
364 }
365
366 fn file_len(&self) -> Result<u64> {
367 Ok(self.file.metadata()?.len())
368 }
369
370 fn read_exact(&mut self, buf: &mut [u8]) -> Result<()> {
371 let next_offset = checked_offset_add(self.offset, buf.len())?;
372 self.file.read_exact(buf)?;
373 self.offset = next_offset;
374 Ok(())
375 }
376}
377
378pub struct WalRecordIter {
380 reader: WalReader,
381}
382
383impl Iterator for WalRecordIter {
384 type Item = Result<WalRecord>;
385
386 fn next(&mut self) -> Option<Self::Item> {
387 match self.reader.next_record() {
388 Ok(Some(record)) => Some(Ok(record)),
389 Ok(None) => None,
390 Err(e) => Some(Err(e)),
391 }
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::record::RecordType;
399 use crate::writer::WalWriter;
400
401 #[test]
402 fn write_then_read_roundtrip() {
403 let dir = tempfile::tempdir().unwrap();
404 let path = dir.path().join("test.wal");
405
406 {
408 let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
409 writer
410 .append(RecordType::Put as u32, 1, 0, 0, b"first")
411 .unwrap();
412 writer
413 .append(RecordType::Put as u32, 2, 1, 0, b"second")
414 .unwrap();
415 writer
416 .append(RecordType::Delete as u32, 1, 0, 0, b"third")
417 .unwrap();
418 writer.sync().unwrap();
419 }
420
421 let reader = WalReader::open(&path, None).unwrap();
423 let records: Vec<_> = reader.records().collect::<Result<_>>().unwrap();
424
425 assert_eq!(records.len(), 3);
426 assert_eq!(records[0].header.lsn, 1);
427 assert_eq!(records[0].header.tenant_id, 1);
428 assert_eq!(records[0].payload, b"first");
429
430 assert_eq!(records[1].header.lsn, 2);
431 assert_eq!(records[1].header.tenant_id, 2);
432 assert_eq!(records[1].header.vshard_id, 1);
433 assert_eq!(records[1].payload, b"second");
434
435 assert_eq!(records[2].header.lsn, 3);
436 assert_eq!(records[2].header.record_type, RecordType::Delete as u32);
437 assert_eq!(records[2].payload, b"third");
438 }
439
440 #[test]
441 fn empty_wal_yields_no_records() {
442 let dir = tempfile::tempdir().unwrap();
443 let path = dir.path().join("empty.wal");
444
445 {
447 let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
448 writer.sync().unwrap();
449 }
450
451 let reader = WalReader::open(&path, None).unwrap();
452 let records: Vec<_> = reader.records().collect::<Result<_>>().unwrap();
453 assert!(records.is_empty());
454 }
455
456 #[test]
457 fn truncated_file_stops_at_committed_prefix() {
458 let dir = tempfile::tempdir().unwrap();
459 let path = dir.path().join("truncated.wal");
460
461 {
463 let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
464 writer
465 .append(RecordType::Put as u32, 1, 0, 0, b"good-record")
466 .unwrap();
467 writer.sync().unwrap();
468 }
469
470 {
472 use std::io::Write;
473 let mut file = std::fs::OpenOptions::new()
474 .append(true)
475 .open(&path)
476 .unwrap();
477 file.write_all(b"GARBAGE_PARTIAL_RECORD").unwrap();
478 }
479
480 let reader = WalReader::open(&path, None).unwrap();
482 let records: Vec<_> = reader.records().collect::<Result<_>>().unwrap();
483 assert_eq!(records.len(), 1);
484 assert_eq!(records[0].payload, b"good-record");
485 }
486
487 #[test]
488 fn oversized_payload_header_is_a_typed_error_before_allocation() {
489 let dir = tempfile::tempdir().unwrap();
490 let path = dir.path().join("oversized.wal");
491 let header = RecordHeader {
492 magic: crate::record::WAL_MAGIC,
493 format_version: crate::record::WAL_FORMAT_VERSION,
494 record_type: RecordType::Put as u32,
495 lsn: 1,
496 tenant_id: 1,
497 vshard_id: 0,
498 payload_len: (crate::record::MAX_WAL_PAYLOAD_SIZE + 1) as u32,
499 database_id: 0,
500 apply_key: 0,
501 event_source: crate::record::NO_EVENT_SOURCE,
502 commit_hlc: 0,
503 crc32c: 0,
504 };
505 std::fs::write(&path, header.to_bytes()).unwrap();
506
507 let mut reader = WalReader::open(&path, None).unwrap();
508 assert!(matches!(
509 reader.next_record(),
510 Err(WalError::PayloadTooLarge { .. })
511 ));
512 }
513
514 #[test]
515 fn checked_offset_add_rejects_overflow() {
516 assert!(checked_offset_add(u64::MAX, 1).is_err());
517 }
518
519 #[test]
520 fn skip_many_unknown_optional_records_is_iterative() {
521 const UNKNOWN_OPTIONAL: u32 = 99; const SKIP_COUNT: usize = 50_000;
529
530 let dir = tempfile::tempdir().unwrap();
531 let path = dir.path().join("many_unknown.wal");
532
533 {
534 let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
535 for _ in 0..SKIP_COUNT {
536 writer
537 .append(UNKNOWN_OPTIONAL, 1, 0, 0, b"skip-me")
538 .unwrap();
539 }
540 writer
541 .append(RecordType::Put as u32, 1, 0, 0, b"keep-me")
542 .unwrap();
543 writer.sync().unwrap();
544 }
545
546 let reader = WalReader::open(&path, None).unwrap();
547 let records: Vec<_> = reader.records().collect::<Result<_>>().unwrap();
548
549 assert_eq!(records.len(), 1);
552 assert_eq!(records[0].payload, b"keep-me");
553 }
554
555 #[test]
563 fn an_unknown_format_version_is_an_error_and_a_zeroed_one_is_a_stop() {
564 let dir = tempfile::tempdir().unwrap();
565 let framed = dir.path().join("framed.wal");
566 {
567 let mut writer = WalWriter::open_without_direct_io(&framed).unwrap();
568 writer
569 .append(RecordType::Put as u32, 1, 0, 0, b"x")
570 .unwrap();
571 writer.sync().unwrap();
572 }
573
574 let mut bytes = std::fs::read(&framed).unwrap();
575 crate::record::foreign_version::reseal_with_version(
576 &mut bytes,
577 0,
578 crate::record::WAL_FORMAT_VERSION + 1,
579 );
580 let unknown = dir.path().join("unknown.wal");
581 std::fs::write(&unknown, &bytes).unwrap();
582
583 let reader = WalReader::open_raw(&unknown).unwrap();
584 match reader.records().collect::<Result<Vec<_>>>() {
585 Err(WalError::UnsupportedVersion { version, supported }) => {
586 assert_eq!(version, crate::record::WAL_FORMAT_VERSION + 1);
587 assert_eq!(supported, crate::record::WAL_FORMAT_VERSION);
588 }
589 other => panic!("expected UnsupportedVersion, got {other:?}"),
590 }
591
592 bytes[4..6].copy_from_slice(&0u16.to_le_bytes());
594 let zeroed = dir.path().join("zeroed.wal");
595 std::fs::write(&zeroed, &bytes).unwrap();
596
597 let mut reader = WalReader::open_raw(&zeroed).unwrap();
598 let mut records = Vec::new();
599 while let Some(record) = reader
600 .next_record()
601 .expect("a zeroed version stops the stream, it does not fail it")
602 {
603 records.push(record);
604 }
605 assert!(
606 records.is_empty(),
607 "nothing before the stop is a readable record"
608 );
609 assert!(
612 matches!(reader.stop_reason(), Some(StopReason::Corruption { .. })),
613 "a zeroed version must be a corruption stop, not a quiet end: {:?}",
614 reader.stop_reason()
615 );
616 }
617
618 #[test]
626 fn a_version_mismatch_after_the_first_record_is_damage() {
627 let dir = tempfile::tempdir().unwrap();
628 let path = dir.path().join("two-records.wal");
629 {
630 let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
631 writer
632 .append(RecordType::Put as u32, 1, 0, 0, b"first")
633 .unwrap();
634 writer
635 .append(RecordType::Put as u32, 1, 0, 0, b"second")
636 .unwrap();
637 writer.sync().unwrap();
638 }
639
640 let mut reader = WalReader::open_raw(&path).unwrap();
641 let first = reader
642 .next_record()
643 .unwrap()
644 .expect("the first record reads");
645 assert_eq!(first.header.lsn, 1, "records before the damage still read");
646 let second_at = reader.committed_end();
647
648 let mut bytes = std::fs::read(&path).unwrap();
649 let version_at = usize::try_from(second_at).unwrap() + 4;
650 bytes[version_at..version_at + 2]
651 .copy_from_slice(&(crate::record::WAL_FORMAT_VERSION + 1).to_le_bytes());
652 std::fs::write(&path, &bytes).unwrap();
653
654 let mut reader = WalReader::open_raw(&path).unwrap();
655 assert!(
656 reader.next_record().unwrap().is_some(),
657 "the first record is still readable"
658 );
659 assert!(
660 reader.next_record().unwrap().is_none(),
661 "the mismatch stops the stream instead of failing the read"
662 );
663 assert!(
664 matches!(reader.stop_reason(), Some(StopReason::Corruption { .. })),
665 "a mismatch after the first record is damage, not a format gap: {:?}",
666 reader.stop_reason()
667 );
668 }
669}