use std::fs::File;
use crate::Lsn;
use crate::error::{Result, WalError};
use crate::segment::{self, HEADER_SIZE, ScanOutcome};
use crate::wal::TailState;
#[derive(Debug)]
pub(crate) struct SegmentRecovery {
pub(crate) max_lsn: Lsn,
pub(crate) write_offset: u64,
pub(crate) tail_state: TailState,
}
struct RecoverCtx {
base_lsn: Lsn,
is_active: bool,
segment_size: u64,
max_record_size: u32,
}
pub(crate) fn recover_segment(
file: &File,
base_lsn: Lsn,
is_active: bool,
segment_size: u64,
max_record_size: u32,
) -> Result<SegmentRecovery> {
let ctx = RecoverCtx {
base_lsn,
is_active,
segment_size,
max_record_size,
};
let mut buf = Vec::new();
let mut offset = HEADER_SIZE;
let mut expected = base_lsn;
let mut last_valid = Lsn(base_lsn.0 - 1);
loop {
match segment::read_record_at(file, offset, segment_size, max_record_size, &mut buf)? {
ScanOutcome::Record {
lsn, framed_len, ..
} => {
if lsn != expected {
return classify(file, &ctx, offset, expected, last_valid, &mut buf);
}
last_valid = lsn;
offset += framed_len as u64;
expected = lsn.next();
}
ScanOutcome::CleanEnd => {
return Ok(SegmentRecovery {
max_lsn: last_valid,
write_offset: offset,
tail_state: TailState::Clean,
});
}
ScanOutcome::Invalid => {
return classify(file, &ctx, offset, expected, last_valid, &mut buf);
}
}
}
}
fn classify(
file: &File,
ctx: &RecoverCtx,
x: u64,
expected: Lsn,
last_valid: Lsn,
buf: &mut Vec<u8>,
) -> Result<SegmentRecovery> {
if !ctx.is_active {
return Err(WalError::Corruption {
segment: ctx.base_lsn,
offset: x,
detail: "invalid record in sealed segment",
});
}
if forward_scan_finds_valid(file, ctx, x, expected, buf)? {
return Err(WalError::TornMidLog {
segment: ctx.base_lsn,
offset: x,
});
}
segment::zero_to_eof(file, x, ctx.segment_size)?;
Ok(SegmentRecovery {
max_lsn: last_valid,
write_offset: x,
tail_state: TailState::TruncatedAt {
segment_base: ctx.base_lsn,
offset: x,
},
})
}
fn forward_scan_finds_valid(
file: &File,
ctx: &RecoverCtx,
x: u64,
expected: Lsn,
buf: &mut Vec<u8>,
) -> Result<bool> {
let bound = scan_bound(ctx.max_record_size);
let start = x.saturating_add(8);
let end = start.saturating_add(bound);
let mut p = start;
while p <= end {
#[cfg(feature = "fuzzing")]
{
let distance = p - start;
scan_probe::record(distance);
assert!(
distance <= scan_bound(ctx.max_record_size),
"forward scan exceeded its bound: distance {distance} > {} \
(max_record_size {})",
scan_bound(ctx.max_record_size),
ctx.max_record_size,
);
}
if let ScanOutcome::Record { lsn, .. } =
segment::read_record_at(file, p, ctx.segment_size, ctx.max_record_size, buf)?
{
if lsn >= expected {
return Ok(true);
}
}
p += 8;
}
Ok(false)
}
#[inline]
pub(crate) fn scan_bound(max_record_size: u32) -> u64 {
u64::from(max_record_size) + 28
}
#[cfg(feature = "fuzzing")]
pub(crate) mod scan_probe {
use std::cell::Cell;
thread_local! {
static PEAK: Cell<u64> = const { Cell::new(0) };
}
pub(crate) fn reset() {
PEAK.with(|p| p.set(0));
}
pub(crate) fn peak() -> u64 {
PEAK.with(Cell::get)
}
pub(crate) fn record(distance: u64) {
PEAK.with(|p| {
if distance > p.get() {
p.set(distance);
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::record;
use std::os::unix::fs::FileExt;
const SEGMENT_SIZE: u64 = 64 * 1024;
const MAX_RECORD_SIZE: u32 = 4096;
fn fresh_segment(dir: &std::path::Path, base: Lsn) -> File {
segment::create(dir, base, SEGMENT_SIZE).unwrap()
}
fn write_dense(file: &File, base: Lsn, payloads: &[&[u8]]) -> u64 {
let mut offset = HEADER_SIZE;
let mut lsn = base;
let mut buf = Vec::new();
for p in payloads {
buf.clear();
let framed = record::encode_into(&mut buf, lsn, p);
file.write_all_at(&buf, offset).unwrap();
offset += framed as u64;
lsn = lsn.next();
}
file.sync_data().unwrap();
offset
}
#[test]
fn empty_active_segment_is_clean_with_base_minus_one() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(101));
let rec = recover_segment(&file, Lsn(101), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert_eq!(rec.max_lsn, Lsn(100));
assert_eq!(rec.write_offset, HEADER_SIZE);
assert_eq!(rec.tail_state, TailState::Clean);
}
#[test]
fn clean_run_recovers_all_records_without_zeroing() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
let end = write_dense(&file, Lsn(1), &[b"a", b"bb", b"ccc"]);
let rec = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert_eq!(rec.max_lsn, Lsn(3));
assert_eq!(rec.write_offset, end);
assert_eq!(rec.tail_state, TailState::Clean);
}
#[test]
fn corrupt_last_record_is_a_torn_tail_and_is_zeroed() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
write_dense(&file, Lsn(1), &[b"first", b"second"]);
let x = HEADER_SIZE + record::framed_size(5) as u64;
let mut byte = [0u8; 1];
file.read_at(&mut byte, x + 20).unwrap();
byte[0] ^= 0xFF;
file.write_all_at(&byte, x + 20).unwrap();
let rec = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert_eq!(rec.max_lsn, Lsn(1));
assert_eq!(rec.write_offset, x);
assert_eq!(
rec.tail_state,
TailState::TruncatedAt {
segment_base: Lsn(1),
offset: x
}
);
let mut tail = vec![0xAAu8; (SEGMENT_SIZE - x) as usize];
file.read_at(&mut tail, x).unwrap();
assert!(tail.iter().all(|&b| b == 0), "tail must be zeroed to EOF");
}
#[test]
fn corrupt_middle_acked_record_is_fatal_tornmidlog() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
write_dense(&file, Lsn(1), &[b"one", b"two", b"three"]);
let x = HEADER_SIZE + record::framed_size(3) as u64;
let mut byte = [0u8; 1];
file.read_at(&mut byte, x + 20).unwrap();
byte[0] ^= 0xFF;
file.write_all_at(&byte, x + 20).unwrap();
let err = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap_err();
assert!(
matches!(err, WalError::TornMidLog { segment, offset } if segment == Lsn(1) && offset == x),
"expected TornMidLog at {x}, got {err:?}"
);
}
#[test]
fn sealed_segment_invalid_record_is_fatal_corruption() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
write_dense(&file, Lsn(1), &[b"one", b"two", b"three"]);
let x = HEADER_SIZE + record::framed_size(3) as u64;
let mut byte = [0u8; 1];
file.read_at(&mut byte, x + 20).unwrap();
byte[0] ^= 0xFF;
file.write_all_at(&byte, x + 20).unwrap();
let err = recover_segment(&file, Lsn(1), false, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap_err();
assert!(
matches!(err, WalError::Corruption { segment, offset, .. } if segment == Lsn(1) && offset == x),
"expected Corruption at {x}, got {err:?}"
);
}
#[test]
fn sealed_torn_last_record_is_also_fatal() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
write_dense(&file, Lsn(1), &[b"first", b"second"]);
let x = HEADER_SIZE + record::framed_size(5) as u64;
let mut byte = [0u8; 1];
file.read_at(&mut byte, x + 20).unwrap();
byte[0] ^= 0xFF;
file.write_all_at(&byte, x + 20).unwrap();
assert!(matches!(
recover_segment(&file, Lsn(1), false, SEGMENT_SIZE, MAX_RECORD_SIZE),
Err(WalError::Corruption { .. })
));
assert!(matches!(
recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE),
Ok(SegmentRecovery {
tail_state: TailState::TruncatedAt { .. },
..
})
));
}
#[test]
fn recovery_is_idempotent_after_torn_tail() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
write_dense(&file, Lsn(1), &[b"first", b"second"]);
let x = HEADER_SIZE + record::framed_size(5) as u64;
let mut byte = [0u8; 1];
file.read_at(&mut byte, x + 20).unwrap();
byte[0] ^= 0xFF;
file.write_all_at(&byte, x + 20).unwrap();
let first = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert!(matches!(first.tail_state, TailState::TruncatedAt { .. }));
for _ in 0..3 {
let again =
recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert_eq!(again.max_lsn, Lsn(1));
assert_eq!(again.write_offset, x);
assert_eq!(again.tail_state, TailState::Clean);
}
}
#[test]
fn truncated_file_below_segment_size_recovers_without_panic() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
let end = write_dense(&file, Lsn(1), &[b"alpha", b"beta", b"gamma"]);
file.set_len(end - 4).unwrap();
let rec = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert_eq!(rec.max_lsn, Lsn(2));
assert!(matches!(rec.tail_state, TailState::TruncatedAt { .. }));
assert_eq!(file.metadata().unwrap().len(), SEGMENT_SIZE);
}
#[test]
fn zeroing_prevents_resurrection_of_stale_valid_record() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
let x = write_dense(&file, Lsn(1), &[b"first"]);
let mut torn = Vec::new();
record::encode_into(&mut torn, Lsn(2), b"torn-r2");
torn[0] ^= 0xFF; file.write_all_at(&torn, x).unwrap();
let bound = u64::from(MAX_RECORD_SIZE) + 28;
let stale_off = x + bound + 256;
let mut stale = Vec::new();
record::encode_into(&mut stale, Lsn(2), b"STALE-RESURRECTABLE");
file.write_all_at(&stale, stale_off).unwrap();
file.sync_data().unwrap();
let mut buf = Vec::new();
assert!(matches!(
segment::read_record_at(&file, stale_off, SEGMENT_SIZE, MAX_RECORD_SIZE, &mut buf),
Ok(ScanOutcome::Record { lsn: Lsn(2), .. })
));
let rec = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert_eq!(rec.max_lsn, Lsn(1));
assert!(matches!(rec.tail_state, TailState::TruncatedAt { offset, .. } if offset == x));
assert!(matches!(
segment::read_record_at(&file, stale_off, SEGMENT_SIZE, MAX_RECORD_SIZE, &mut buf),
Ok(ScanOutcome::CleanEnd)
));
let again = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert_eq!(again.max_lsn, Lsn(1));
assert_eq!(again.write_offset, x);
assert_eq!(again.tail_state, TailState::Clean);
}
fn write_record_at(file: &File, offset: u64, lsn: Lsn, payload: &[u8]) {
let mut buf = Vec::new();
record::encode_into(&mut buf, lsn, payload);
file.write_all_at(&buf, offset).unwrap();
}
fn write_torn_at(file: &File, offset: u64, lsn: Lsn, payload: &[u8]) -> u64 {
let mut buf = Vec::new();
let framed = record::encode_into(&mut buf, lsn, payload);
buf[0] ^= 0xFF; file.write_all_at(&buf, offset).unwrap();
framed as u64
}
#[test]
fn forward_scan_just_within_bound_is_fatal_tornmidlog() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
let x = write_dense(&file, Lsn(1), &[b"first"]); write_torn_at(&file, x, Lsn(2), b"z");
let bound = u64::from(MAX_RECORD_SIZE) + 28;
let end = x + 8 + bound;
let within = (end / 8) * 8; assert!(within > x + 8 && within <= end);
write_record_at(&file, within, Lsn(2), b"genuine-continuation");
file.sync_data().unwrap();
let err = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap_err();
assert!(
matches!(err, WalError::TornMidLog { offset, .. } if offset == x),
"a continuation at the bound edge must be fatal, got {err:?}"
);
}
#[test]
fn forward_scan_just_beyond_bound_is_torn_tail() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
let x = write_dense(&file, Lsn(1), &[b"first"]);
write_torn_at(&file, x, Lsn(2), b"z");
let bound = u64::from(MAX_RECORD_SIZE) + 28;
let end = x + 8 + bound;
let beyond = (end / 8) * 8 + 8; assert!(beyond > end);
write_record_at(&file, beyond, Lsn(2), b"genuine-continuation");
file.sync_data().unwrap();
let rec = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert_eq!(rec.max_lsn, Lsn(1));
assert!(matches!(rec.tail_state, TailState::TruncatedAt { offset, .. } if offset == x));
let mut buf = Vec::new();
assert!(matches!(
segment::read_record_at(&file, beyond, SEGMENT_SIZE, MAX_RECORD_SIZE, &mut buf),
Ok(ScanOutcome::CleanEnd)
));
}
#[test]
fn unknown_rec_type_at_tail_is_treated_as_torn_tail() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
let x = write_dense(&file, Lsn(1), &[b"first"]);
let mut buf = Vec::new();
let framed = record::encode_into(&mut buf, Lsn(2), b"reserved-type");
buf[16] = 2; let crc = crate::crc::crc32c(&buf[4..framed]);
buf[0..4].copy_from_slice(&crc.to_le_bytes());
file.write_all_at(&buf, x).unwrap();
file.sync_data().unwrap();
let rec = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert_eq!(rec.max_lsn, Lsn(1));
assert!(matches!(rec.tail_state, TailState::TruncatedAt { offset, .. } if offset == x));
}
#[test]
fn rec_type_zeroed_interior_is_fatal_tornmidlog() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
write_dense(&file, Lsn(1), &[b"one", b"two", b"three"]);
let x = HEADER_SIZE + record::framed_size(3) as u64;
file.write_all_at(&[0u8], x + 16).unwrap();
file.sync_data().unwrap();
let err = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap_err();
assert!(
matches!(err, WalError::TornMidLog { segment, offset } if segment == Lsn(1) && offset == x),
"interior rec_type→0 corruption must be fatal TornMidLog at {x}, got {err:?}"
);
}
#[test]
fn rec_type_zeroed_at_tail_is_torn_tail_and_zeroed() {
let dir = tempfile::tempdir().unwrap();
let file = fresh_segment(dir.path(), Lsn(1));
write_dense(&file, Lsn(1), &[b"first", b"second"]);
let x = HEADER_SIZE + record::framed_size(5) as u64;
file.write_all_at(&[0u8], x + 16).unwrap();
file.sync_data().unwrap();
let rec = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert_eq!(rec.max_lsn, Lsn(1));
assert_eq!(rec.write_offset, x);
assert!(matches!(rec.tail_state, TailState::TruncatedAt { offset, .. } if offset == x));
let again = recover_segment(&file, Lsn(1), true, SEGMENT_SIZE, MAX_RECORD_SIZE).unwrap();
assert_eq!(again.max_lsn, Lsn(1));
assert_eq!(again.write_offset, x);
assert_eq!(again.tail_state, TailState::Clean);
}
#[test]
fn arbitrary_bytes_never_panic_and_terminate() {
let dir = tempfile::tempdir().unwrap();
for seed in 0u64..64 {
let base = Lsn(seed + 1);
let file = fresh_segment(dir.path(), base);
let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1);
let mut bytes = vec![0u8; 4096];
for b in &mut bytes {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
*b = (state >> 33) as u8;
}
file.write_all_at(&bytes, HEADER_SIZE).unwrap();
let _ = recover_segment(&file, base, true, SEGMENT_SIZE, MAX_RECORD_SIZE);
}
}
}