use std::fs::{File, OpenOptions};
use std::io::{self, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::datatypes::Value;
pub const WAL_MAGIC: [u8; 4] = *b"KWAL";
pub const WAL_FORMAT_VERSION: u8 = 3;
pub const MIN_READABLE_WAL_FORMAT_VERSION: u8 = 2;
const MAX_WAL_FRAME_BYTES: u64 = u32::MAX as u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DurabilityLevel {
Off,
Normal,
#[default]
Full,
}
impl DurabilityLevel {
#[inline]
pub fn logs(self) -> bool {
!matches!(self, Self::Off)
}
#[inline]
pub fn sync_mode(self) -> Option<SyncMode> {
match self {
Self::Off => None,
Self::Normal => Some(SyncMode::PageCache),
Self::Full => Some(SyncMode::Barrier),
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
"full" => Some(Self::Full),
"normal" => Some(Self::Normal),
"off" => Some(Self::Off),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Self::Off => "off",
Self::Normal => "normal",
Self::Full => "full",
}
}
pub const NAMES: [&'static str; 3] = ["full", "normal", "off"];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncMode {
Barrier,
PageCache,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum MutationOp {
UpsertNode {
node_type: String,
id: Value,
title: Value,
properties: Vec<(String, Value)>,
},
RemoveNode { node_type: String, id: Value },
UpsertEdge {
conn_type: String,
src_type: String,
src_id: Value,
tgt_type: String,
tgt_id: Value,
properties: Vec<(String, Value)>,
},
RemoveEdge {
conn_type: String,
src_type: String,
src_id: Value,
tgt_type: String,
tgt_id: Value,
},
SetNodeLabels {
node_type: String,
id: Value,
labels: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WalFrame {
pub lsn: u64,
pub ops: Vec<MutationOp>,
}
fn crc32_table() -> &'static [u32; 256] {
static TABLE: OnceLock<[u32; 256]> = OnceLock::new();
TABLE.get_or_init(|| {
let mut table = [0u32; 256];
let mut n = 0;
while n < 256 {
let mut c = n as u32;
let mut k = 0;
while k < 8 {
c = if c & 1 != 0 {
0xEDB8_8320 ^ (c >> 1)
} else {
c >> 1
};
k += 1;
}
table[n] = c;
n += 1;
}
table
})
}
pub fn crc32(data: &[u8]) -> u32 {
let table = crc32_table();
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc = table[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8);
}
crc ^ 0xFFFF_FFFF
}
pub fn write_header(w: &mut impl Write) -> io::Result<()> {
write_header_version(w, WAL_FORMAT_VERSION)
}
fn write_header_version(w: &mut impl Write, version: u8) -> io::Result<()> {
w.write_all(&WAL_MAGIC)?;
w.write_all(&[version])?;
Ok(())
}
pub fn append_frame(w: &mut impl Write, frame: &WalFrame) -> io::Result<()> {
append_frame_with_codec(w, frame, crate::serde_codec::CURRENT_CODEC)
}
fn append_frame_with_codec(
w: &mut impl Write,
frame: &WalFrame,
codec: crate::serde_codec::CodecVersion,
) -> io::Result<()> {
let payload = crate::serde_codec::encode_versioned(codec, frame, MAX_WAL_FRAME_BYTES)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let len = u32::try_from(payload.len())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "WAL frame exceeds 4 GiB"))?;
let crc = crc32(&payload);
let mut framed = Vec::with_capacity(8 + payload.len());
framed.extend_from_slice(&len.to_le_bytes());
framed.extend_from_slice(&crc.to_le_bytes());
framed.extend_from_slice(&payload);
w.write_all(&framed)?;
Ok(())
}
fn read_exact_opt(r: &mut impl Read, buf: &mut [u8]) -> io::Result<Option<()>> {
match r.read_exact(buf) {
Ok(()) => Ok(Some(())),
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => Ok(None),
Err(e) => Err(e),
}
}
pub fn read_header(r: &mut impl Read) -> io::Result<u8> {
let mut magic = [0u8; 4];
r.read_exact(&mut magic)?;
if magic != WAL_MAGIC {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"not a kglite WAL file (bad magic)",
));
}
let mut ver = [0u8; 1];
r.read_exact(&mut ver)?;
Ok(ver[0])
}
pub fn read_frames(r: impl Read, stream_len: u64) -> io::Result<Vec<WalFrame>> {
let (frames, diagnostic) = read_frames_diagnosed(r, stream_len)?;
if let Some(message) = diagnostic {
eprintln!("{message}");
}
Ok(frames)
}
fn read_frames_diagnosed(
mut r: impl Read,
stream_len: u64,
) -> io::Result<(Vec<WalFrame>, Option<String>)> {
let version = read_header(&mut r)?;
let codec = wal_codec(version)?;
let header_len = (WAL_MAGIC.len() + 1) as u64;
let mut consumed: u64 = header_len;
let mut frames = Vec::new();
let stopped_at = loop {
match read_frame_step(&mut r, stream_len, consumed, codec)? {
FrameStep::Frame(frame, frame_len) => {
frames.push(frame);
consumed += frame_len;
}
FrameStep::Eof => break None,
FrameStep::Torn => break Some((consumed, None)),
FrameStep::Corrupt(frame_len) => break Some((consumed, Some(consumed + frame_len))),
}
};
let diagnostic = stopped_at.map(|(offset, resume)| {
let trailing = resume.map_or(0, |next| {
count_intact_frames(&mut r, stream_len, next, codec)
});
recovery_diagnostic(offset, stream_len, frames.len(), trailing)
});
Ok((frames, diagnostic))
}
enum FrameStep {
Frame(WalFrame, u64),
Eof,
Torn,
Corrupt(u64),
}
fn read_frame_step(
r: &mut impl Read,
stream_len: u64,
frame_start: u64,
codec: crate::serde_codec::CodecVersion,
) -> io::Result<FrameStep> {
let mut len_buf = [0u8; 4];
if read_exact_opt(r, &mut len_buf)?.is_none() {
return Ok(if frame_start == stream_len {
FrameStep::Eof
} else {
FrameStep::Torn
});
}
let mut crc_buf = [0u8; 4];
if read_exact_opt(r, &mut crc_buf)?.is_none() {
return Ok(FrameStep::Torn); }
let after_header = frame_start + 8;
let len = u32::from_le_bytes(len_buf) as u64;
let expected_crc = u32::from_le_bytes(crc_buf);
if len == 0 {
return Ok(FrameStep::Torn);
}
if len > stream_len.saturating_sub(after_header) {
return Ok(FrameStep::Torn);
}
let mut payload = vec![0u8; len as usize];
if read_exact_opt(r, &mut payload)?.is_none() {
return Ok(FrameStep::Torn); }
let frame_len = 8 + len;
if crc32(&payload) != expected_crc {
return Ok(FrameStep::Corrupt(frame_len));
}
let limits = crate::serde_codec::DecodeLimits::new(MAX_WAL_FRAME_BYTES, len);
match crate::serde_codec::decode_exact_with::<WalFrame>(codec, &payload, len, limits) {
Ok(frame) => Ok(FrameStep::Frame(frame, frame_len)),
Err(_) => Ok(FrameStep::Corrupt(frame_len)),
}
}
fn count_intact_frames(
r: &mut impl Read,
stream_len: u64,
mut consumed: u64,
codec: crate::serde_codec::CodecVersion,
) -> usize {
let mut count = 0;
while let Ok(FrameStep::Frame(_, frame_len)) = read_frame_step(r, stream_len, consumed, codec) {
count += 1;
consumed += frame_len;
}
count
}
fn recovery_diagnostic(offset: u64, stream_len: u64, recovered: usize, trailing: usize) -> String {
if trailing == 0 {
return format!(
"[kglite] WAL recovery stopped at a torn/corrupt frame at byte offset {offset} \
(of {stream_len}); recovered {recovered} intact frame(s) before it. This is expected \
after a crash mid-commit; the torn tail is discarded and will be truncated at \
the next checkpoint."
);
}
let discarded = stream_len.saturating_sub(offset);
format!(
"[kglite] WAL recovery stopped at a corrupt frame at byte offset {offset} \
(of {stream_len}); recovered {recovered} intact frame(s) before it. At least \
{trailing} later frame(s) still decode cleanly, and all {discarded} byte(s) from \
the stop point to the end of the file are discarded: a frame's effect depends on \
every frame before it, so the log cannot be trusted past the corruption. This looks \
like mid-file damage rather than a crash tail — committed work is being dropped. \
Check the storage this log lives on, and treat the last checkpoint plus the \
{recovered} recovered frame(s) as the surviving state."
)
}
fn wal_codec(version: u8) -> io::Result<crate::serde_codec::CodecVersion> {
match version {
MIN_READABLE_WAL_FORMAT_VERSION..=WAL_FORMAT_VERSION => {
Ok(crate::serde_codec::CodecVersion::PostcardV1)
}
1 => Err(crate::graph::io::file::pre_014_bincode_error(
"WAL format v1",
)),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"unsupported WAL format version {version} (this build reads \
v{MIN_READABLE_WAL_FORMAT_VERSION}-v{WAL_FORMAT_VERSION}). \
A WAL newer than the binary cannot be replayed safely: open \
the graph with a matching kglite build to recover it, or \
delete the '-wal' sidecar to discard work committed since \
the last save() checkpoint."
),
)),
}
}
pub fn wal_path(checkpoint: &Path) -> PathBuf {
let mut s = checkpoint.as_os_str().to_owned();
s.push("-wal");
PathBuf::from(s)
}
pub fn recover(path: &Path) -> io::Result<Vec<WalFrame>> {
match File::open(path) {
Ok(f) => {
let len = f.metadata()?.len();
read_frames(BufReader::new(f), len)
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
fn sync_parent_dir(path: &Path) {
if let Some(dir) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
if let Ok(dirfile) = File::open(dir) {
let _ = dirfile.sync_all();
}
}
}
fn truncate_to_header(file: &mut File) -> io::Result<()> {
use std::io::{Seek, SeekFrom};
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
write_header(file)?;
file.sync_all()
}
fn prepare_wal_file(path: &Path) -> io::Result<()> {
use std::io::{Seek, SeekFrom};
let header_len = (WAL_MAGIC.len() + 1) as u64;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)?;
let file_len = file.metadata()?.len();
if file_len == 0 {
write_header(&mut file)?;
file.sync_all()?;
sync_parent_dir(path);
return Ok(());
}
let mut header = [0u8; 5];
let read_len = file_len.min(header_len) as usize;
file.read_exact(&mut header[..read_len])?;
let magic_ok = read_len >= WAL_MAGIC.len() && header[..4] == WAL_MAGIC;
if file_len < header_len || (!magic_ok && file_len == header_len) {
return truncate_to_header(&mut file);
}
if !magic_ok {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{} is not a kglite WAL file (bad magic) and is not empty; \
refusing to overwrite it. Move the file aside if it is stale.",
path.display()
),
));
}
wal_codec(header[4])?;
if header[4] != WAL_FORMAT_VERSION {
file.seek(SeekFrom::Start(WAL_MAGIC.len() as u64))?;
file.write_all(&[WAL_FORMAT_VERSION])?;
file.sync_data()?;
}
Ok(())
}
#[derive(Debug)]
pub struct Wal {
file: File,
path: PathBuf,
sync: SyncMode,
}
impl Wal {
pub fn open(path: PathBuf, sync: SyncMode) -> io::Result<Self> {
prepare_wal_file(&path)?;
let file = OpenOptions::new().read(true).append(true).open(&path)?;
Ok(Self { file, path, sync })
}
pub fn append(&mut self, frame: &WalFrame) -> io::Result<()> {
append_frame(&mut self.file, frame)?;
self.file.flush()?;
if self.sync == SyncMode::Barrier {
self.file.sync_data()?;
}
Ok(())
}
pub fn sync(&mut self) -> io::Result<()> {
self.file.flush()?;
self.file.sync_data()
}
pub fn reset(&mut self) -> io::Result<()> {
let mut file = OpenOptions::new().read(true).write(true).open(&self.path)?;
truncate_to_header(&mut file)
}
pub fn path(&self) -> &Path {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use tempfile::TempDir;
fn sample_ops() -> Vec<MutationOp> {
vec![
MutationOp::UpsertNode {
node_type: "Person".to_string(),
id: Value::Int64(1),
title: Value::String("Alice".to_string()),
properties: vec![
("age".to_string(), Value::Int64(30)),
("city".to_string(), Value::String("Oslo".to_string())),
],
},
MutationOp::UpsertEdge {
conn_type: "KNOWS".to_string(),
src_type: "Person".to_string(),
src_id: Value::Int64(1),
tgt_type: "Person".to_string(),
tgt_id: Value::Int64(2),
properties: vec![("since".to_string(), Value::Int64(2020))],
},
MutationOp::RemoveNode {
node_type: "Person".to_string(),
id: Value::Int64(9),
},
]
}
fn write_wal(frames: &[WalFrame]) -> Vec<u8> {
write_wal_version(frames, WAL_FORMAT_VERSION)
}
fn write_wal_version(frames: &[WalFrame], version: u8) -> Vec<u8> {
let mut buf = Vec::new();
write_header_version(&mut buf, version).unwrap();
let codec = wal_codec(version).unwrap();
for f in frames {
append_frame_with_codec(&mut buf, f, codec).unwrap();
}
buf
}
fn read_frames_all(bytes: Vec<u8>) -> io::Result<Vec<WalFrame>> {
let len = bytes.len() as u64;
read_frames(Cursor::new(bytes), len)
}
fn open_wal(path: PathBuf) -> io::Result<Wal> {
Wal::open(path, SyncMode::Barrier)
}
#[test]
fn crc32_matches_known_vector() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
assert_eq!(crc32(b""), 0);
}
#[test]
fn single_frame_round_trips() {
let frame = WalFrame {
lsn: 1,
ops: sample_ops(),
};
let bytes = write_wal(std::slice::from_ref(&frame));
let got = read_frames_all(bytes).unwrap();
assert_eq!(got, vec![frame]);
}
#[test]
fn multiple_frames_preserve_order() {
let frames = vec![
WalFrame {
lsn: 1,
ops: vec![MutationOp::RemoveNode {
node_type: "T".into(),
id: Value::Int64(1),
}],
},
WalFrame {
lsn: 2,
ops: sample_ops(),
},
WalFrame {
lsn: 3,
ops: vec![],
},
];
let bytes = write_wal(&frames);
let got = read_frames_all(bytes).unwrap();
assert_eq!(got, frames);
}
#[test]
fn torn_trailing_frame_is_discarded() {
let frames = vec![
WalFrame {
lsn: 1,
ops: sample_ops(),
},
WalFrame {
lsn: 2,
ops: sample_ops(),
},
];
let mut bytes = write_wal(&frames);
bytes.truncate(bytes.len() - 5);
let got = read_frames_all(bytes).unwrap();
assert_eq!(got, vec![frames[0].clone()]);
}
#[test]
fn truncated_in_length_prefix_is_clean_stop() {
let frames = vec![WalFrame {
lsn: 1,
ops: sample_ops(),
}];
let mut bytes = write_wal(&frames);
bytes.extend_from_slice(&[0u8, 0u8]);
let got = read_frames_all(bytes).unwrap();
assert_eq!(got, frames);
}
#[test]
fn corrupt_payload_crc_mismatch_stops() {
let frame = WalFrame {
lsn: 1,
ops: sample_ops(),
};
let mut bytes = write_wal(std::slice::from_ref(&frame));
let last = bytes.len() - 1;
bytes[last] ^= 0xFF;
let got = read_frames_all(bytes).unwrap();
assert!(got.is_empty(), "corrupt frame must not be returned");
}
#[test]
fn header_only_wal_yields_no_frames() {
let bytes = write_wal(&[]);
let got = read_frames_all(bytes).unwrap();
assert!(got.is_empty());
}
#[test]
fn bad_magic_is_rejected() {
let bytes = b"XXXX\x02".to_vec();
assert!(read_frames_all(bytes).is_err());
}
#[test]
fn legacy_v1_is_rejected_before_frame_recovery() {
let bytes = b"KWAL\x01".to_vec();
let error = read_frames_all(bytes).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("pre-0.14"));
}
#[test]
fn unknown_version_is_rejected_without_payload_sniffing() {
let bytes = b"KWAL\x7f".to_vec();
let error = read_frames_all(bytes).unwrap_err();
assert!(error
.to_string()
.contains("unsupported WAL format version 127"));
}
#[test]
fn empty_reader_is_error() {
let bytes: Vec<u8> = Vec::new();
assert!(read_frames_all(bytes).is_err());
}
#[test]
fn variant_tags_are_stable_on_disk_format() {
let id = || Value::Int64(1);
let cases: [(u8, MutationOp); 5] = [
(
0,
MutationOp::UpsertNode {
node_type: "T".into(),
id: id(),
title: Value::Null,
properties: vec![],
},
),
(
1,
MutationOp::RemoveNode {
node_type: "T".into(),
id: id(),
},
),
(
2,
MutationOp::UpsertEdge {
conn_type: "C".into(),
src_type: "T".into(),
src_id: id(),
tgt_type: "T".into(),
tgt_id: id(),
properties: vec![],
},
),
(
3,
MutationOp::RemoveEdge {
conn_type: "C".into(),
src_type: "T".into(),
src_id: id(),
tgt_type: "T".into(),
tgt_id: id(),
},
),
(
4,
MutationOp::SetNodeLabels {
node_type: "T".into(),
id: id(),
labels: vec![],
},
),
];
for (tag, op) in cases {
let mut buf = Vec::new();
append_frame(
&mut buf,
&WalFrame {
lsn: 1,
ops: vec![op.clone()],
},
)
.unwrap();
assert_eq!(
buf[8 + 2],
tag,
"variant tag for {op:?} moved — this breaks every WAL on disk"
);
}
}
#[test]
fn v2_frames_replay_exactly_under_current_schema() {
let frames = vec![
WalFrame {
lsn: 1,
ops: sample_ops(),
},
WalFrame {
lsn: 2,
ops: sample_ops(),
},
];
let bytes = write_wal_version(&frames, MIN_READABLE_WAL_FORMAT_VERSION);
assert_eq!(bytes[4], 2, "fixture must carry a v2 header");
assert_eq!(read_frames_all(bytes).unwrap(), frames);
}
#[test]
fn open_upgrades_readable_older_header_and_keeps_frames() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("g.kgl-wal");
std::fs::write(
&p,
write_wal_version(&[frame(1)], MIN_READABLE_WAL_FORMAT_VERSION),
)
.unwrap();
let mut wal = open_wal(p.clone()).unwrap();
wal.append(&WalFrame {
lsn: 2,
ops: vec![MutationOp::SetNodeLabels {
node_type: "Person".into(),
id: Value::Int64(1),
labels: vec!["Employee".into()],
}],
})
.unwrap();
drop(wal);
assert_eq!(
std::fs::read(&p).unwrap()[4],
WAL_FORMAT_VERSION,
"header must be upgraded before newer frames are appended"
);
let got = recover(&p).unwrap();
assert_eq!(got.iter().map(|f| f.lsn).collect::<Vec<_>>(), [1, 2]);
assert_eq!(got[0], frame(1), "the pre-upgrade frame is unchanged");
}
#[test]
fn newer_wal_is_refused_with_actionable_message() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("g.kgl-wal");
let mut header = WAL_MAGIC.to_vec();
header.push(WAL_FORMAT_VERSION + 1);
std::fs::write(&p, &header).unwrap();
for message in [
open_wal(p.clone()).unwrap_err().to_string(),
recover(&p).unwrap_err().to_string(),
] {
assert!(
message.contains("unsupported WAL format version"),
"{message}"
);
assert!(message.contains("matching kglite build"), "{message}");
}
}
fn frame(lsn: u64) -> WalFrame {
WalFrame {
lsn,
ops: sample_ops(),
}
}
#[test]
fn open_creates_with_header_and_appends_survive_reopen() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("g.kgl-wal");
{
let mut wal = open_wal(p.clone()).unwrap();
wal.append(&frame(1)).unwrap();
wal.append(&frame(2)).unwrap();
} {
let mut wal = open_wal(p.clone()).unwrap();
wal.append(&frame(3)).unwrap();
}
let frames = recover(&p).unwrap();
assert_eq!(frames.iter().map(|f| f.lsn).collect::<Vec<_>>(), [1, 2, 3]);
}
#[test]
fn open_rejects_legacy_wal_before_append() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("g.kgl-wal");
std::fs::write(&p, b"KWAL\x01").unwrap();
let error = open_wal(p).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn reset_truncates_to_header_only() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("g.kgl-wal");
let mut wal = open_wal(p.clone()).unwrap();
wal.append(&frame(1)).unwrap();
wal.append(&frame(2)).unwrap();
wal.reset().unwrap();
assert!(recover(&p).unwrap().is_empty());
wal.append(&frame(5)).unwrap();
assert_eq!(
recover(&p)
.unwrap()
.iter()
.map(|f| f.lsn)
.collect::<Vec<_>>(),
[5]
);
}
#[test]
fn recover_missing_file_is_empty() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("does-not-exist.kgl-wal");
assert!(recover(&p).unwrap().is_empty());
}
#[test]
fn wal_path_appends_suffix() {
assert_eq!(
wal_path(Path::new("/data/graph.kgl")),
PathBuf::from("/data/graph.kgl-wal")
);
}
#[test]
fn open_repairs_torn_header() {
for torn_len in 0..5usize {
let dir = TempDir::new().unwrap();
let p = dir.path().join("g.kgl-wal");
std::fs::write(&p, &WAL_MAGIC[..torn_len.min(4)]).unwrap();
let mut wal = open_wal(p.clone()).unwrap();
wal.append(&frame(1)).unwrap();
drop(wal);
let frames = recover(&p).unwrap();
assert_eq!(
frames.iter().map(|f| f.lsn).collect::<Vec<_>>(),
[1],
"torn header of {torn_len} bytes must be repaired"
);
}
}
#[test]
fn open_repairs_header_sized_bad_magic() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("g.kgl-wal");
std::fs::write(&p, b"XXXXX").unwrap();
let mut wal = open_wal(p.clone()).unwrap();
wal.append(&frame(7)).unwrap();
drop(wal);
assert_eq!(recover(&p).unwrap().len(), 1);
}
#[test]
fn open_refuses_bad_magic_with_data() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("g.kgl-wal");
std::fs::write(&p, b"not a wal file at all").unwrap();
let err = open_wal(p.clone()).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert_eq!(std::fs::read(&p).unwrap(), b"not a wal file at all");
}
#[test]
fn corrupt_giant_length_prefix_is_bounded() {
let frames = vec![frame(1), frame(2)];
let mut bytes = write_wal(&frames);
bytes.extend_from_slice(&u32::MAX.to_le_bytes()); bytes.extend_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); bytes.extend_from_slice(b"tiny tail, nowhere near 4 GiB");
let got = read_frames_all(bytes).unwrap();
assert_eq!(got, frames, "intact frames before the bad prefix survive");
}
#[test]
fn garbage_mid_file_stops_at_first_bad_frame() {
let good = vec![frame(1), frame(2)];
let mut bytes = write_wal(&good);
let mut corrupt = Vec::new();
append_frame(&mut corrupt, &frame(3)).unwrap();
corrupt[10] ^= 0xFF; bytes.extend_from_slice(&corrupt);
append_frame(&mut bytes, &frame(4)).unwrap();
let got = read_frames_all(bytes).unwrap();
assert_eq!(got.iter().map(|f| f.lsn).collect::<Vec<_>>(), [1, 2]);
}
#[test]
fn open_fresh_file_is_immediately_recoverable() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("g.kgl-wal");
let _wal = open_wal(p.clone()).unwrap();
assert!(recover(&p).unwrap().is_empty());
}
#[test]
fn level_maps_to_sync_mode_and_round_trips_by_name() {
assert_eq!(DurabilityLevel::Off.sync_mode(), None);
assert_eq!(
DurabilityLevel::Normal.sync_mode(),
Some(SyncMode::PageCache)
);
assert_eq!(DurabilityLevel::Full.sync_mode(), Some(SyncMode::Barrier));
assert!(!DurabilityLevel::Off.logs());
assert!(DurabilityLevel::Normal.logs());
assert!(DurabilityLevel::Full.logs());
assert_eq!(DurabilityLevel::default(), DurabilityLevel::Full);
for name in DurabilityLevel::NAMES {
let level = DurabilityLevel::from_name(name).expect("listed name must parse");
assert_eq!(level.name(), name);
}
assert_eq!(DurabilityLevel::from_name("fsync"), None);
assert_eq!(DurabilityLevel::from_name("FULL"), None);
}
#[test]
fn page_cache_appends_are_recoverable() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("g.kgl-wal");
{
let mut wal = Wal::open(p.clone(), SyncMode::PageCache).unwrap();
wal.append(&frame(1)).unwrap();
wal.append(&frame(2)).unwrap();
}
let got = recover(&p).unwrap();
assert_eq!(got.iter().map(|f| f.lsn).collect::<Vec<_>>(), [1, 2]);
}
#[test]
fn explicit_sync_preserves_frames_at_every_mode() {
for mode in [SyncMode::Barrier, SyncMode::PageCache] {
let dir = TempDir::new().unwrap();
let p = dir.path().join("g.kgl-wal");
let mut wal = Wal::open(p.clone(), mode).unwrap();
wal.append(&frame(1)).unwrap();
wal.sync().unwrap();
wal.append(&frame(2)).unwrap();
wal.sync().unwrap();
drop(wal);
assert_eq!(
recover(&p)
.unwrap()
.iter()
.map(|f| f.lsn)
.collect::<Vec<_>>(),
[1, 2],
"sync() must not disturb the log at {mode:?}"
);
}
}
#[test]
fn zero_filled_hole_is_treated_as_a_torn_tail() {
let good = vec![frame(1), frame(2)];
let mut bytes = write_wal(&good);
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
append_frame(&mut bytes, &frame(3)).unwrap();
let got = read_frames_all(bytes).unwrap();
assert_eq!(got.iter().map(|f| f.lsn).collect::<Vec<_>>(), [1, 2]);
}
fn frame_offset(frames: &[WalFrame], n: usize) -> usize {
write_wal(&frames[..n]).len()
}
#[test]
fn mid_stream_corruption_is_reported_as_mid_file_damage() {
let frames = vec![frame(1), frame(2), frame(3), frame(4)];
let mut bytes = write_wal(&frames);
let stop = frame_offset(&frames, 1);
bytes[stop + 8] ^= 0xFF;
let stream_len = bytes.len() as u64;
let (got, message) = read_frames_diagnosed(Cursor::new(bytes), stream_len).unwrap();
assert_eq!(
got.iter().map(|f| f.lsn).collect::<Vec<_>>(),
[1],
"recovery still stops at the first bad frame"
);
let message = message.expect("an early stop must produce a diagnostic");
assert!(
message.contains(&format!("byte offset {stop} ")),
"{message}"
);
assert!(message.contains("mid-file damage"), "{message}");
assert!(message.contains("At least 2 later frame(s)"), "{message}");
assert!(
message.contains(&format!("{} byte(s)", stream_len - stop as u64)),
"the discarded byte count must be reported: {message}"
);
assert!(
!message.contains("expected after a crash mid-commit"),
"mid-file damage must not be filed as a routine crash tail: {message}"
);
}
#[test]
fn trailing_frames_after_a_corrupt_one_are_counted() {
let frames = vec![frame(1), frame(2), frame(3), frame(4)];
let mut bytes = write_wal(&frames);
let stop = frame_offset(&frames, 1);
bytes[stop + 8] ^= 0xFF;
let stream_len = bytes.len() as u64;
let corrupt_frame_len = (frame_offset(&frames, 2) - stop) as u64;
let mut r = Cursor::new(bytes);
let mut skip = vec![0u8; frame_offset(&frames, 2)];
std::io::Read::read_exact(&mut r, &mut skip).unwrap();
let after_corrupt = stop as u64 + corrupt_frame_len;
assert_eq!(
count_intact_frames(
&mut r,
stream_len,
after_corrupt,
crate::serde_codec::CodecVersion::PostcardV1
),
2
);
}
#[test]
fn a_torn_tail_keeps_the_crash_wording() {
let frames = vec![frame(1), frame(2)];
let mut bytes = write_wal(&frames);
bytes.truncate(bytes.len() - 5);
let stream_len = bytes.len() as u64;
let (got, message) = read_frames_diagnosed(Cursor::new(bytes), stream_len).unwrap();
assert_eq!(got, vec![frames[0].clone()]);
let message = message.expect("a torn tail must still produce a diagnostic");
assert!(
message.contains("expected after a crash mid-commit"),
"{message}"
);
assert!(message.contains("the torn tail is discarded"), "{message}");
assert!(!message.contains("mid-file damage"), "{message}");
}
#[test]
fn a_hole_is_never_probed_past() {
let mut bytes = write_wal(&[frame(1)]);
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
append_frame(&mut bytes, &frame(2)).unwrap();
let stream_len = bytes.len() as u64;
let (got, message) = read_frames_diagnosed(Cursor::new(bytes), stream_len).unwrap();
assert_eq!(got.iter().map(|f| f.lsn).collect::<Vec<_>>(), [1]);
let message = message.expect("a hole must still produce a diagnostic");
assert!(
!message.contains("mid-file damage"),
"a hole gives no frame boundary, so nothing past it may be claimed: {message}"
);
}
#[test]
fn zero_page_after_frames_recovers_the_prefix() {
let mut bytes = write_wal(&[frame(1)]);
bytes.extend_from_slice(&[0u8; 4096]);
let got = read_frames_all(bytes).unwrap();
assert_eq!(got, vec![frame(1)]);
}
struct CountingWriter {
inner: Vec<u8>,
writes: usize,
}
impl Write for CountingWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.writes += 1;
self.inner.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn frame_is_emitted_in_a_single_write() {
let mut w = CountingWriter {
inner: Vec::new(),
writes: 0,
};
append_frame(&mut w, &frame(1)).unwrap();
assert_eq!(w.writes, 1, "a frame must not be split across writes");
let mut bytes = Vec::new();
write_header(&mut bytes).unwrap();
bytes.extend_from_slice(&w.inner);
assert_eq!(read_frames_all(bytes).unwrap(), vec![frame(1)]);
}
}