use fsqlite_error::{FrankenError, Result};
use fsqlite_types::cx::Cx;
use fsqlite_types::flags::SyncFlags;
use fsqlite_vfs::VfsFile;
use tracing::{debug, error};
use crate::checksum::{
SqliteWalChecksum, WAL_FORMAT_VERSION, WAL_FRAME_HEADER_SIZE, WAL_HEADER_SIZE, WAL_MAGIC_LE,
WalFrameHeader, WalHeader, WalSalts, compute_wal_frame_checksum, read_wal_header_checksum,
wal_header_checksum, write_wal_frame_checksum, write_wal_frame_salts,
};
pub struct WalFile<F: VfsFile> {
file: F,
page_size: usize,
big_endian_checksum: bool,
header: WalHeader,
running_checksum: SqliteWalChecksum,
frame_count: usize,
}
impl<F: VfsFile> WalFile<F> {
pub fn refresh(&mut self, cx: &Cx) -> Result<()> {
let frame_size = self.frame_size();
let expected_size = u64::try_from(WAL_HEADER_SIZE)
.expect("WAL header size fits u64")
.saturating_add(
u64::try_from(self.frame_count)
.unwrap_or(u64::MAX)
.saturating_mul(u64::try_from(frame_size).unwrap_or(u64::MAX)),
);
let file_size = self.file.file_size(cx)?;
if file_size == expected_size {
return Ok(());
}
if file_size < expected_size {
return self.rebuild_state_from_file(cx);
}
let mut header_buf = [0u8; WAL_HEADER_SIZE];
let header_read = self.file.read(cx, &mut header_buf, 0)?;
if header_read < WAL_HEADER_SIZE {
return Err(FrankenError::WalCorrupt {
detail: format!(
"WAL file too small for header during refresh: read {header_read}, need {WAL_HEADER_SIZE}"
),
});
}
let disk_header = WalHeader::from_bytes(&header_buf)?;
let disk_big_endian = disk_header.big_endian_checksum();
let disk_header_checksum = read_wal_header_checksum(&header_buf)?;
let expected_header_checksum = wal_header_checksum(&header_buf, disk_big_endian)?;
if disk_header_checksum != expected_header_checksum {
return Err(FrankenError::WalCorrupt {
detail: "WAL header checksum mismatch during refresh".to_owned(),
});
}
if disk_header.magic != self.header.magic
|| disk_header.format_version != self.header.format_version
|| disk_header.page_size != self.header.page_size
|| disk_header.salts != self.header.salts
{
return self.rebuild_state_from_file(cx);
}
let frame_size_u64 = u64::try_from(frame_size).unwrap_or(u64::MAX);
let available_frames = usize::try_from(
file_size.saturating_sub(u64::try_from(WAL_HEADER_SIZE).unwrap_or(0)) / frame_size_u64,
)
.unwrap_or(usize::MAX);
if available_frames <= self.frame_count {
return Ok(());
}
let mut frame_header_buf = [0u8; WAL_FRAME_HEADER_SIZE];
for frame_index in self.frame_count..available_frames {
let offset = self.frame_offset(frame_index);
let bytes_read = self.file.read(cx, &mut frame_header_buf, offset)?;
if bytes_read < WAL_FRAME_HEADER_SIZE {
break; }
let frame_header = WalFrameHeader::from_bytes(&frame_header_buf)?;
if frame_header.salts != self.header.salts {
break; }
self.running_checksum = frame_header.checksum;
self.frame_count += 1;
}
Ok(())
}
fn rebuild_state_from_file(&mut self, cx: &Cx) -> Result<()> {
let mut header_buf = [0u8; WAL_HEADER_SIZE];
let header_read = self.file.read(cx, &mut header_buf, 0)?;
if header_read < WAL_HEADER_SIZE {
return Err(FrankenError::WalCorrupt {
detail: format!(
"WAL file too small for header during rebuild: read {header_read}, need {WAL_HEADER_SIZE}"
),
});
}
let header = WalHeader::from_bytes(&header_buf)?;
let page_size = usize::try_from(header.page_size).expect("WAL header page size fits usize");
let big_endian_checksum = header.big_endian_checksum();
let header_checksum = read_wal_header_checksum(&header_buf)?;
let expected_header_checksum = wal_header_checksum(&header_buf, big_endian_checksum)?;
if header_checksum != expected_header_checksum {
return Err(FrankenError::WalCorrupt {
detail: "WAL header checksum mismatch during rebuild".to_owned(),
});
}
self.header = header;
self.page_size = page_size;
self.big_endian_checksum = big_endian_checksum;
self.running_checksum = header_checksum;
self.frame_count = 0;
let frame_size = self.frame_size();
let file_size = self.file.file_size(cx)?;
let max_frames = usize::try_from(
file_size.saturating_sub(u64::try_from(WAL_HEADER_SIZE).unwrap_or(0))
/ u64::try_from(frame_size).unwrap_or(1),
)
.unwrap_or(usize::MAX);
let mut frame_buf = vec![0u8; frame_size];
for frame_index in 0..max_frames {
let offset = self.frame_offset(frame_index);
let bytes_read = self.file.read(cx, &mut frame_buf, offset)?;
if bytes_read < frame_size {
break;
}
let frame_header = WalFrameHeader::from_bytes(&frame_buf[..WAL_FRAME_HEADER_SIZE])?;
if frame_header.salts != self.header.salts {
break;
}
let expected = compute_wal_frame_checksum(
&frame_buf,
self.page_size,
self.running_checksum,
self.big_endian_checksum,
)?;
if frame_header.checksum != expected {
break;
}
self.running_checksum = expected;
self.frame_count += 1;
}
Ok(())
}
#[must_use]
pub fn frame_size(&self) -> usize {
WAL_FRAME_HEADER_SIZE + self.page_size
}
#[allow(clippy::cast_possible_truncation)]
fn frame_offset(&self, index: usize) -> u64 {
let header_size = WAL_HEADER_SIZE as u64;
let idx = index as u64;
let frame_sz = self.frame_size() as u64;
header_size + idx * frame_sz
}
#[must_use]
pub fn frame_count(&self) -> usize {
self.frame_count
}
#[must_use]
pub fn header(&self) -> &WalHeader {
&self.header
}
#[must_use]
pub fn page_size(&self) -> usize {
self.page_size
}
#[must_use]
pub fn big_endian_checksum(&self) -> bool {
self.big_endian_checksum
}
#[must_use]
pub fn running_checksum(&self) -> SqliteWalChecksum {
self.running_checksum
}
pub fn create(
cx: &Cx,
mut file: F,
page_size: u32,
checkpoint_seq: u32,
salts: WalSalts,
) -> Result<Self> {
let header = WalHeader {
magic: WAL_MAGIC_LE,
format_version: WAL_FORMAT_VERSION,
page_size,
checkpoint_seq,
salts,
checksum: SqliteWalChecksum::default(), };
let header_bytes = header.to_bytes()?;
file.write(cx, &header_bytes, 0)?;
file.truncate(
cx,
u64::try_from(WAL_HEADER_SIZE).expect("header size fits u64"),
)?;
let running_checksum = read_wal_header_checksum(&header_bytes)?;
debug!(
page_size,
checkpoint_seq,
salt1 = header.salts.salt1,
salt2 = header.salts.salt2,
"WAL file created"
);
Ok(Self {
file,
page_size: usize::try_from(page_size).expect("page size fits usize"),
big_endian_checksum: false,
header,
running_checksum,
frame_count: 0,
})
}
#[allow(clippy::too_many_lines)]
pub fn open(cx: &Cx, mut file: F) -> Result<Self> {
let mut header_buf = [0u8; WAL_HEADER_SIZE];
let bytes_read = file.read(cx, &mut header_buf, 0)?;
if bytes_read < WAL_HEADER_SIZE {
return Err(FrankenError::WalCorrupt {
detail: format!(
"WAL file too small for header: read {bytes_read}, need {WAL_HEADER_SIZE}"
),
});
}
let header = WalHeader::from_bytes(&header_buf)?;
let page_size = usize::try_from(header.page_size).expect("WAL header page size fits usize");
let big_endian_checksum = header.big_endian_checksum();
let frame_size = WAL_FRAME_HEADER_SIZE + page_size;
let header_checksum = read_wal_header_checksum(&header_buf)?;
let expected_checksum =
crate::checksum::wal_header_checksum(&header_buf, big_endian_checksum)?;
if header_checksum != expected_checksum {
error!("WAL header checksum mismatch — file may be corrupt");
return Err(FrankenError::WalCorrupt {
detail: "WAL header checksum mismatch".to_owned(),
});
}
let file_size = file.file_size(cx)?;
let data_bytes =
file_size.saturating_sub(u64::try_from(WAL_HEADER_SIZE).expect("header size fits u64"));
let max_frames = usize::try_from(data_bytes / u64::try_from(frame_size).unwrap_or(1))
.unwrap_or(usize::MAX);
let mut running_checksum = header_checksum;
let mut valid_frames = 0_usize;
let mut frame_buf = vec![0u8; frame_size];
for frame_index in 0..max_frames {
let header_size = WAL_HEADER_SIZE as u64;
let idx = frame_index as u64;
let frame_sz = frame_size as u64;
let file_offset = header_size + idx * frame_sz;
let bytes_read = file.read(cx, &mut frame_buf, file_offset)?;
if bytes_read < frame_size {
break; }
let frame_header = WalFrameHeader::from_bytes(&frame_buf[..WAL_FRAME_HEADER_SIZE])?;
if frame_header.salts != header.salts {
error!(frame_index, "WAL frame salt mismatch — chain terminated");
break; }
let expected = compute_wal_frame_checksum(
&frame_buf,
page_size,
running_checksum,
big_endian_checksum,
)?;
if frame_header.checksum != expected {
error!(
frame_index,
"WAL frame checksum mismatch — chain terminated"
);
break; }
running_checksum = expected;
valid_frames += 1;
}
debug!(
page_size,
big_endian_checksum,
checkpoint_seq = header.checkpoint_seq,
valid_frames,
"WAL file opened"
);
Ok(Self {
file,
page_size,
big_endian_checksum,
header,
running_checksum,
frame_count: valid_frames,
})
}
pub fn append_frame(
&mut self,
cx: &Cx,
page_number: u32,
page_data: &[u8],
db_size_if_commit: u32,
) -> Result<()> {
if page_data.len() != self.page_size {
return Err(FrankenError::WalCorrupt {
detail: format!(
"page data size mismatch: expected {}, got {}",
self.page_size,
page_data.len()
),
});
}
let frame_size = self.frame_size();
let mut frame = vec![0u8; frame_size];
frame[..4].copy_from_slice(&page_number.to_be_bytes());
frame[4..8].copy_from_slice(&db_size_if_commit.to_be_bytes());
write_wal_frame_salts(&mut frame[..WAL_FRAME_HEADER_SIZE], self.header.salts)?;
frame[WAL_FRAME_HEADER_SIZE..].copy_from_slice(page_data);
let new_checksum = write_wal_frame_checksum(
&mut frame,
self.page_size,
self.running_checksum,
self.big_endian_checksum,
)?;
let offset = self.frame_offset(self.frame_count);
self.file.write(cx, &frame, offset)?;
self.running_checksum = new_checksum;
self.frame_count += 1;
let bytes_written = u64::try_from(frame_size).unwrap_or(u64::MAX);
let span = tracing::span!(
tracing::Level::DEBUG,
"wal_write",
frame_count = self.frame_count,
bytes_written = bytes_written,
page_number = page_number,
is_commit = db_size_if_commit > 0,
);
let _guard = span.enter();
debug!(
frame_index = self.frame_count - 1,
page_number,
is_commit = db_size_if_commit > 0,
"WAL frame appended"
);
crate::metrics::GLOBAL_WAL_METRICS.record_frame_write(bytes_written);
Ok(())
}
pub fn read_frame(&mut self, cx: &Cx, frame_index: usize) -> Result<(WalFrameHeader, Vec<u8>)> {
let frame_size = self.frame_size();
let mut buf = vec![0u8; frame_size];
let header = self.read_frame_into(cx, frame_index, &mut buf)?;
let page_data = buf[WAL_FRAME_HEADER_SIZE..].to_vec();
Ok((header, page_data))
}
pub fn read_frame_into(
&mut self,
cx: &Cx,
frame_index: usize,
buf: &mut [u8],
) -> Result<WalFrameHeader> {
if frame_index >= self.frame_count {
return Err(FrankenError::WalCorrupt {
detail: format!(
"frame index {frame_index} out of range (count: {})",
self.frame_count
),
});
}
let frame_size = self.frame_size();
if buf.len() < frame_size {
return Err(FrankenError::Internal(format!(
"read_frame_into buffer too small: got {}, need {}",
buf.len(),
frame_size
)));
}
let offset = self.frame_offset(frame_index);
let bytes_read = self.file.read(cx, &mut buf[..frame_size], offset)?;
if bytes_read < frame_size {
return Err(FrankenError::WalCorrupt {
detail: format!(
"short read at frame {frame_index}: got {bytes_read}, need {frame_size}"
),
});
}
WalFrameHeader::from_bytes(&buf[..WAL_FRAME_HEADER_SIZE])
}
pub fn read_frame_header(&mut self, cx: &Cx, frame_index: usize) -> Result<WalFrameHeader> {
if frame_index >= self.frame_count {
return Err(FrankenError::WalCorrupt {
detail: format!(
"frame index {frame_index} out of range (count: {})",
self.frame_count
),
});
}
let mut header_buf = [0u8; WAL_FRAME_HEADER_SIZE];
let offset = self.frame_offset(frame_index);
let bytes_read = self.file.read(cx, &mut header_buf, offset)?;
if bytes_read < WAL_FRAME_HEADER_SIZE {
return Err(FrankenError::WalCorrupt {
detail: format!("short header read at frame {frame_index}: got {bytes_read}"),
});
}
WalFrameHeader::from_bytes(&header_buf)
}
pub fn last_commit_frame(&mut self, cx: &Cx) -> Result<Option<usize>> {
let mut last = None;
for i in 0..self.frame_count {
let header = self.read_frame_header(cx, i)?;
if header.is_commit() {
last = Some(i);
}
}
Ok(last)
}
pub fn sync(&mut self, cx: &Cx, flags: SyncFlags) -> Result<()> {
self.file.sync(cx, flags)
}
pub fn reset(&mut self, cx: &Cx, new_checkpoint_seq: u32, new_salts: WalSalts) -> Result<()> {
let new_header = WalHeader {
magic: self.header.magic,
format_version: WAL_FORMAT_VERSION,
page_size: self.header.page_size,
checkpoint_seq: new_checkpoint_seq,
salts: new_salts,
checksum: SqliteWalChecksum::default(),
};
let header_bytes = new_header.to_bytes()?;
self.file.write(cx, &header_bytes, 0)?;
self.file.truncate(
cx,
u64::try_from(WAL_HEADER_SIZE).expect("header size fits u64"),
)?;
self.running_checksum = read_wal_header_checksum(&header_bytes)?;
self.header = WalHeader::from_bytes(&header_bytes)?;
self.frame_count = 0;
debug!(
checkpoint_seq = new_checkpoint_seq,
salt1 = new_salts.salt1,
salt2 = new_salts.salt2,
"WAL reset"
);
crate::metrics::GLOBAL_WAL_METRICS.record_wal_reset();
Ok(())
}
pub fn close(mut self, cx: &Cx) -> Result<()> {
self.file.close(cx)
}
#[must_use]
pub fn file(&self) -> &F {
&self.file
}
pub fn file_mut(&mut self) -> &mut F {
&mut self.file
}
}
#[cfg(test)]
mod tests {
use fsqlite_types::flags::VfsOpenFlags;
use fsqlite_vfs::MemoryVfs;
use fsqlite_vfs::traits::Vfs;
use super::*;
const PAGE_SIZE: u32 = 4096;
fn test_cx() -> Cx {
Cx::default()
}
fn test_salts() -> WalSalts {
WalSalts {
salt1: 0xDEAD_BEEF,
salt2: 0xCAFE_BABE,
}
}
fn sample_page(seed: u8) -> Vec<u8> {
let page_size = usize::try_from(PAGE_SIZE).expect("page size fits usize");
let mut page = vec![0u8; page_size];
for (i, byte) in page.iter_mut().enumerate() {
let reduced = u8::try_from(i % 251).expect("modulo fits u8");
*byte = reduced ^ seed;
}
page
}
fn open_wal_file(vfs: &MemoryVfs, cx: &Cx) -> <MemoryVfs as Vfs>::File {
let flags = VfsOpenFlags::READWRITE | VfsOpenFlags::CREATE | VfsOpenFlags::WAL;
let (file, _) = vfs
.open(cx, Some(std::path::Path::new("test.db-wal")), flags)
.expect("open WAL file");
file
}
#[test]
fn test_create_and_open_empty_wal() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
assert_eq!(wal.frame_count(), 0);
assert_eq!(wal.page_size(), usize::try_from(PAGE_SIZE).unwrap());
assert!(!wal.big_endian_checksum());
assert_eq!(wal.header().checkpoint_seq, 0);
assert_eq!(wal.header().salts, test_salts());
wal.close(&cx).expect("close WAL");
let file2 = open_wal_file(&vfs, &cx);
let wal2 = WalFile::open(&cx, file2).expect("open WAL");
assert_eq!(wal2.frame_count(), 0);
assert_eq!(wal2.header().salts, test_salts());
wal2.close(&cx).expect("close WAL");
}
#[test]
fn test_append_and_read_single_frame() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 1, test_salts()).expect("create WAL");
let page = sample_page(0x42);
wal.append_frame(&cx, 1, &page, 0).expect("append frame");
assert_eq!(wal.frame_count(), 1);
let (header, data) = wal.read_frame(&cx, 0).expect("read frame");
assert_eq!(header.page_number, 1);
assert_eq!(header.db_size, 0);
assert_eq!(header.salts, test_salts());
assert_eq!(data, page);
wal.close(&cx).expect("close WAL");
}
#[test]
fn test_append_commit_frame() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
let page = sample_page(0x10);
wal.append_frame(&cx, 5, &page, 10)
.expect("append commit frame");
let header = wal.read_frame_header(&cx, 0).expect("read header");
assert!(header.is_commit());
assert_eq!(header.db_size, 10);
assert_eq!(header.page_number, 5);
wal.close(&cx).expect("close WAL");
}
#[test]
fn test_multi_frame_checksum_chain() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 3, test_salts()).expect("create WAL");
for i in 0..5u32 {
let page = sample_page(u8::try_from(i).expect("fits"));
let db_size = if i == 4 { 5 } else { 0 };
wal.append_frame(&cx, i + 1, &page, db_size)
.expect("append frame");
}
assert_eq!(wal.frame_count(), 5);
wal.close(&cx).expect("close WAL");
let file2 = open_wal_file(&vfs, &cx);
let mut wal2 = WalFile::open(&cx, file2).expect("open WAL");
assert_eq!(wal2.frame_count(), 5);
for i in 0..5u32 {
let (header, data) = wal2
.read_frame(&cx, usize::try_from(i).unwrap())
.expect("read frame");
assert_eq!(header.page_number, i + 1);
let expected = sample_page(u8::try_from(i).expect("fits"));
assert_eq!(data, expected);
}
let last_header = wal2.read_frame_header(&cx, 4).expect("read header");
assert!(last_header.is_commit());
assert_eq!(last_header.db_size, 5);
wal2.close(&cx).expect("close WAL");
}
#[test]
fn test_last_commit_frame() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
assert_eq!(wal.last_commit_frame(&cx).expect("query"), None);
wal.append_frame(&cx, 1, &sample_page(1), 0)
.expect("append");
assert_eq!(wal.last_commit_frame(&cx).expect("query"), None);
wal.append_frame(&cx, 2, &sample_page(2), 3)
.expect("append");
assert_eq!(wal.last_commit_frame(&cx).expect("query"), Some(1));
wal.append_frame(&cx, 3, &sample_page(3), 0)
.expect("append");
wal.append_frame(&cx, 4, &sample_page(4), 5)
.expect("append");
assert_eq!(wal.last_commit_frame(&cx).expect("query"), Some(3));
wal.close(&cx).expect("close WAL");
}
#[test]
fn test_reset_clears_frames() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
for i in 0..3u8 {
wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), 0)
.expect("append");
}
assert_eq!(wal.frame_count(), 3);
let new_salts = WalSalts {
salt1: 0x1111_2222,
salt2: 0x3333_4444,
};
wal.reset(&cx, 1, new_salts).expect("reset");
assert_eq!(wal.frame_count(), 0);
assert_eq!(wal.header().checkpoint_seq, 1);
assert_eq!(wal.header().salts, new_salts);
wal.append_frame(&cx, 10, &sample_page(0xAA), 1)
.expect("append after reset");
assert_eq!(wal.frame_count(), 1);
wal.close(&cx).expect("close WAL");
let file2 = open_wal_file(&vfs, &cx);
let wal2 = WalFile::open(&cx, file2).expect("open WAL");
assert_eq!(wal2.frame_count(), 1);
assert_eq!(wal2.header().checkpoint_seq, 1);
assert_eq!(wal2.header().salts, new_salts);
wal2.close(&cx).expect("close WAL");
}
#[test]
fn test_page_size_mismatch_rejected() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
let short_page = vec![0u8; 100];
let result = wal.append_frame(&cx, 1, &short_page, 0);
assert!(result.is_err());
let long_page = vec![0u8; 8192];
let result = wal.append_frame(&cx, 1, &long_page, 0);
assert!(result.is_err());
wal.close(&cx).expect("close WAL");
}
#[test]
fn test_frame_index_out_of_range() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
assert!(wal.read_frame(&cx, 0).is_err());
assert!(wal.read_frame_header(&cx, 0).is_err());
wal.append_frame(&cx, 1, &sample_page(0), 0)
.expect("append");
assert!(wal.read_frame(&cx, 0).is_ok());
assert!(wal.read_frame(&cx, 1).is_err());
wal.close(&cx).expect("close WAL");
}
#[test]
fn test_reopen_preserves_checksum_chain() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
for i in 0..3u8 {
wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), 0)
.expect("append");
}
let checksum_after_3 = wal.running_checksum();
wal.close(&cx).expect("close WAL");
let file2 = open_wal_file(&vfs, &cx);
let mut wal2 = WalFile::open(&cx, file2).expect("open WAL");
assert_eq!(wal2.frame_count(), 3);
assert_eq!(wal2.running_checksum(), checksum_after_3);
wal2.append_frame(&cx, 4, &sample_page(3), 0)
.expect("append");
wal2.append_frame(&cx, 5, &sample_page(4), 5)
.expect("append commit");
assert_eq!(wal2.frame_count(), 5);
wal2.close(&cx).expect("close WAL");
let file3 = open_wal_file(&vfs, &cx);
let wal3 = WalFile::open(&cx, file3).expect("open WAL");
assert_eq!(wal3.frame_count(), 5);
wal3.close(&cx).expect("close WAL");
}
#[test]
fn test_sync_does_not_panic() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
wal.append_frame(&cx, 1, &sample_page(0), 1)
.expect("append");
wal.sync(&cx, SyncFlags::NORMAL).expect("sync");
wal.sync(&cx, SyncFlags::FULL).expect("full sync");
wal.close(&cx).expect("close WAL");
}
#[test]
fn test_file_accessors() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
let _size = wal.file().file_size(&cx).expect("file_size");
let _size = wal.file_mut().file_size(&cx).expect("file_size via mut");
wal.close(&cx).expect("close WAL");
}
#[test]
fn test_truncated_wal_recovers_committed_prefix() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
for i in 0..5u8 {
let db_size = if i == 4 { 5 } else { 0 };
wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
.expect("append");
}
assert_eq!(wal.frame_count(), 5);
let frame_size = wal.frame_size();
let truncate_at = WAL_HEADER_SIZE + frame_size * 3 + frame_size / 2;
let truncate_at_u64 = u64::try_from(truncate_at).expect("truncate_at fits u64");
wal.file_mut()
.truncate(&cx, truncate_at_u64)
.expect("truncate");
wal.close(&cx).expect("close WAL");
let file2 = open_wal_file(&vfs, &cx);
let mut wal2 = WalFile::open(&cx, file2).expect("open WAL after truncation");
assert_eq!(
wal2.frame_count(),
3,
"only the 3 complete frames before truncation should survive"
);
for i in 0..3u8 {
let (header, data) = wal2.read_frame(&cx, usize::from(i)).expect("read frame");
assert_eq!(header.page_number, u32::from(i) + 1);
assert_eq!(data, sample_page(i));
}
wal2.close(&cx).expect("close WAL");
}
#[test]
fn test_corrupt_frame_payload_detected_on_reopen() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
for i in 0..5u8 {
let db_size = if i == 4 { 5 } else { 0 };
wal.append_frame(&cx, u32::from(i) + 1, &sample_page(i), db_size)
.expect("append");
}
let frame_size = wal.frame_size();
wal.close(&cx).expect("close WAL");
let corrupt_offset = WAL_HEADER_SIZE + frame_size * 3 + WAL_FRAME_HEADER_SIZE + 42;
let corrupt_offset_u64 = u64::try_from(corrupt_offset).expect("corrupt_offset fits u64");
let mut f = open_wal_file(&vfs, &cx);
let mut buf = [0u8; 1];
f.read(&cx, &mut buf, corrupt_offset_u64)
.expect("read byte");
buf[0] ^= 0xFF;
f.write(&cx, &buf, corrupt_offset_u64)
.expect("write corrupted byte");
drop(f);
let file3 = open_wal_file(&vfs, &cx);
let wal3 = WalFile::open(&cx, file3).expect("open WAL after corruption");
assert_eq!(
wal3.frame_count(),
3,
"frames after corruption point should be discarded"
);
wal3.close(&cx).expect("close WAL");
}
#[test]
fn test_multi_commit_recovery_to_last_valid() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
for i in 1..=3u32 {
let db_size = if i == 3 { 3 } else { 0 };
wal.append_frame(
&cx,
i,
&sample_page(u8::try_from(i).expect("i fits u8")),
db_size,
)
.expect("append");
}
for i in 4..=6u32 {
let db_size = if i == 6 { 6 } else { 0 };
wal.append_frame(
&cx,
i,
&sample_page(u8::try_from(i).expect("i fits u8")),
db_size,
)
.expect("append");
}
assert_eq!(wal.frame_count(), 6);
let frame_size = wal.frame_size();
wal.close(&cx).expect("close WAL");
let corrupt_offset = WAL_HEADER_SIZE + frame_size * 4 + WAL_FRAME_HEADER_SIZE + 10;
let corrupt_offset_u64 = u64::try_from(corrupt_offset).expect("corrupt_offset fits u64");
let mut f = open_wal_file(&vfs, &cx);
let mut buf = [0u8; 1];
f.read(&cx, &mut buf, corrupt_offset_u64).expect("read");
buf[0] ^= 0xAA;
f.write(&cx, &buf, corrupt_offset_u64).expect("corrupt");
drop(f);
let file2 = open_wal_file(&vfs, &cx);
let mut wal2 = WalFile::open(&cx, file2).expect("open WAL after corruption");
assert_eq!(
wal2.frame_count(),
4,
"chain should break at corrupted frame 5, keeping frames 1-4"
);
let header3 = wal2.read_frame_header(&cx, 2).expect("read frame 3 header");
assert!(header3.is_commit(), "frame 3 should be a commit frame");
let header4 = wal2.read_frame_header(&cx, 3).expect("read frame 4 header");
assert!(!header4.is_commit(), "frame 4 is not a commit frame");
wal2.close(&cx).expect("close WAL");
}
#[test]
fn test_wal_growth_bounded_by_restart_checkpoint() {
use crate::checkpoint::{CheckpointMode, CheckpointState};
use crate::checkpoint_executor::CheckpointTarget;
use crate::checkpoint_executor::execute_checkpoint;
use fsqlite_types::PageNumber;
struct DummyTarget;
impl CheckpointTarget for DummyTarget {
fn write_page(&mut self, _: &Cx, _: PageNumber, _: &[u8]) -> fsqlite_error::Result<()> {
Ok(())
}
fn truncate_db(&mut self, _: &Cx, _: u32) -> fsqlite_error::Result<()> {
Ok(())
}
fn sync_db(&mut self, _: &Cx) -> fsqlite_error::Result<()> {
Ok(())
}
}
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
for i in 1..=100u32 {
let seed = u8::try_from(i % 256).expect("seed fits u8");
let db_size = if i % 10 == 0 { i } else { 0 };
wal.append_frame(&cx, (i - 1) % 50 + 1, &sample_page(seed), db_size)
.expect("append");
}
assert_eq!(wal.frame_count(), 100);
let state = CheckpointState {
total_frames: 100,
backfilled_frames: 0,
oldest_reader_frame: None,
};
let mut target = DummyTarget;
let result = execute_checkpoint(&cx, &mut wal, CheckpointMode::Restart, state, &mut target)
.expect("restart checkpoint");
assert_eq!(result.frames_backfilled, 100);
assert!(result.wal_was_reset);
assert_eq!(wal.frame_count(), 0, "WAL should be empty after restart");
wal.append_frame(&cx, 1, &sample_page(0xAA), 1)
.expect("append after reset");
assert_eq!(wal.frame_count(), 1);
assert_eq!(wal.header().checkpoint_seq, 1, "checkpoint_seq incremented");
wal.close(&cx).expect("close WAL");
}
#[test]
fn test_wal_header_corruption_detected() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
wal.append_frame(&cx, 1, &sample_page(1), 1)
.expect("append");
wal.close(&cx).expect("close WAL");
let mut f = open_wal_file(&vfs, &cx);
let corrupted_magic = [0xFF, 0xFF, 0xFF, 0xFF];
f.write(&cx, &corrupted_magic, 0).expect("corrupt header");
drop(f);
let file2 = open_wal_file(&vfs, &cx);
let result = WalFile::open(&cx, file2);
assert!(
result.is_err(),
"opening WAL with corrupted header magic should fail"
);
}
#[test]
fn test_empty_wal_after_crash_reopen() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
wal.close(&cx).expect("close WAL");
let file2 = open_wal_file(&vfs, &cx);
let wal2 = WalFile::open(&cx, file2).expect("reopen empty WAL");
assert_eq!(wal2.frame_count(), 0);
wal2.close(&cx).expect("close WAL");
}
#[test]
fn test_crash_after_single_uncommitted_frame() {
let cx = test_cx();
let vfs = MemoryVfs::new();
let file = open_wal_file(&vfs, &cx);
let mut wal = WalFile::create(&cx, file, PAGE_SIZE, 0, test_salts()).expect("create WAL");
wal.append_frame(&cx, 1, &sample_page(0x77), 0)
.expect("append non-commit");
wal.close(&cx).expect("close WAL");
let file2 = open_wal_file(&vfs, &cx);
let mut wal2 = WalFile::open(&cx, file2).expect("reopen WAL");
assert_eq!(wal2.frame_count(), 1, "valid frame should load");
let header = wal2.read_frame_header(&cx, 0).expect("read header");
assert!(
!header.is_commit(),
"non-commit frame should not be marked as commit"
);
wal2.close(&cx).expect("close WAL");
}
#[test]
fn test_frame_offset_calculation_overflow_safety() {
let page_size: u64 = 4096;
let wal_header_size: u64 = 32;
let wal_frame_header_size: u64 = 24;
let frame_size = wal_frame_header_size + page_size;
let large_index: u64 = 1_042_468;
let idx_u64 = large_index;
let expected_offset = wal_header_size + idx_u64 * frame_size;
let calculated_offset = wal_header_size + idx_u64 * frame_size;
assert_eq!(calculated_offset, expected_offset);
}
}