use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[cfg(test)]
use std::fs::File;
#[cfg(test)]
use std::io::Write;
use super::checksum;
use crate::WriteBatchOp;
use crate::env::{Env, WriteFile, WriteMode};
pub(super) const RECORD_PUT: u8 = 0x01;
pub(super) const RECORD_DELETE: u8 = 0x02;
pub(super) const RECORD_DELETE_RANGE: u8 = 0x03;
pub(super) const RECORD_MERGE: u8 = 0x04;
pub(super) const RECORD_BATCH: u8 = 0x05;
pub(crate) const WAL_MAGIC: [u8; 4] = *b"REGO";
pub(crate) const WAL_STAMP_LEN: usize = 12;
const WAL_FORMAT_V1: u16 = 1;
pub(crate) const MAX_RECORD_LEN: u32 = 1 << 30;
const WAL_HEADER_LEN: usize = 5;
const CHECKSUM_LEN: usize = 4;
pub(crate) struct Wal {
file: Box<dyn WriteFile>,
offset: u64,
path: PathBuf,
parent_synced: bool,
env: Arc<dyn Env>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WalEntry {
Put {
key: Vec<u8>,
value: Vec<u8>,
seq: u64,
},
Delete {
key: Vec<u8>,
seq: u64,
},
DeleteRange {
start: Vec<u8>,
end: Vec<u8>,
seq: u64,
},
Merge {
key: Vec<u8>,
operand: Vec<u8>,
seq: u64,
},
}
impl Wal {
pub(crate) fn create_in(env: &Arc<dyn Env>, path: &Path) -> io::Result<Self> {
let mut file = env.open_write(path, WriteMode::Truncate).map_err(|e| {
io::Error::new(e.kind(), format!("creating wal {}: {e}", path.display()))
})?;
file.write_all(&encode_wal_stamp())?;
Ok(Self {
file,
offset: WAL_STAMP_LEN as u64,
path: path.to_path_buf(),
parent_synced: false,
env: Arc::clone(env),
})
}
#[cfg(test)]
pub(crate) fn create(path: &Path) -> io::Result<Self> {
Self::create_in(&crate::env::std_env(), path)
}
pub(crate) fn append_group(&mut self, bytes: &[u8]) -> io::Result<()> {
if bytes.is_empty() {
return Ok(());
}
debug_assert!(
bytes.len() as u64 <= MAX_RECORD_LEN as u64,
"a group larger than MAX_RECORD_LEN would frame a length that \
could be mistaken for the REGO stamp"
);
self.file.write_all(bytes)?;
self.offset += bytes.len() as u64;
Ok(())
}
pub(crate) fn offset(&self) -> u64 {
self.offset
}
pub(crate) fn rollback_to(&mut self, offset: u64) -> io::Result<()> {
debug_assert!(
offset >= WAL_STAMP_LEN as u64,
"rollback must not truncate into the WAL stamp"
);
self.file.set_len(offset)?;
self.offset = offset;
Ok(())
}
pub(crate) fn append_put(&mut self, key: &[u8], value: &[u8], seq: u64) -> io::Result<()> {
let mut record = Vec::with_capacity(record_len(put_payload_len(key, value)));
encode_put_record(&mut record, key, value, seq);
self.append_group(&record)
}
pub(crate) fn append_delete(&mut self, key: &[u8], seq: u64) -> io::Result<()> {
let mut record = Vec::with_capacity(record_len(delete_payload_len(key)));
encode_record(&mut record, RECORD_DELETE, |out| {
encode_delete_payload(out, key, seq)
});
self.append_group(&record)
}
pub(crate) fn append_merge(&mut self, key: &[u8], operand: &[u8], seq: u64) -> io::Result<()> {
let mut record = Vec::with_capacity(record_len(merge_payload_len(key, operand)));
encode_record(&mut record, RECORD_MERGE, |out| {
encode_merge_payload(out, key, operand, seq)
});
self.append_group(&record)
}
pub(crate) fn append_delete_range(
&mut self,
start: &[u8],
end: &[u8],
seq: u64,
) -> io::Result<()> {
let mut record = Vec::with_capacity(record_len(delete_range_payload_len(start, end)));
encode_record(&mut record, RECORD_DELETE_RANGE, |out| {
encode_delete_range_payload(out, start, end, seq)
});
self.append_group(&record)
}
pub(crate) fn sync_data(&mut self) -> io::Result<()> {
let env = Arc::clone(&self.env);
self.sync_with_parent_sync(move |p| crate::env::sync_parent_dir(&*env, p))
}
fn sync_with_parent_sync(
&mut self,
mut sync_parent: impl FnMut(&Path) -> io::Result<()>,
) -> io::Result<()> {
#[cfg(test)]
if fault::should_fail_sync(&self.path) {
return Err(io::Error::other("injected WAL sync failure"));
}
self.file.sync_data()?;
if !self.parent_synced {
sync_parent(&self.path)?;
self.parent_synced = true;
}
Ok(())
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
#[cfg(test)]
pub(crate) fn replay(path: &Path) -> io::Result<Vec<WalEntry>> {
let mut iter = super::wal_replay::WalReplayIter::open(
&crate::env::std_env(),
path,
super::wal_replay::WalPosition::Newest,
)?;
let mut entries = Vec::new();
while let Some(entry) = iter.next_entry()? {
entries.push(entry);
}
Ok(entries)
}
pub(crate) fn remove_in(env: &dyn Env, path: &Path) -> io::Result<()> {
crate::env::remove_file_and_sync_parent(env, path)
}
#[cfg(test)]
pub(crate) fn remove(path: &Path) -> io::Result<()> {
Self::remove_in(&*crate::env::std_env(), path)
}
}
#[cfg(test)]
pub(crate) mod fault {
use std::path::{Path, PathBuf};
use crate::sync::Mutex;
static ARMED: Mutex<Vec<Arm>> = Mutex::new(Vec::new());
struct Arm {
dir: PathBuf,
period: u64,
seen: u64,
}
pub(crate) fn arm_sync_failure(dir: &Path) {
arm(dir, 0);
}
pub(crate) fn arm_flapping_sync_failure(dir: &Path, period: u64) {
assert!(period > 1, "a flapping fault needs a period above one");
arm(dir, period);
}
fn arm(dir: &Path, period: u64) {
let mut armed = ARMED.lock();
armed.push(Arm {
dir: dir.to_path_buf(),
period,
seen: 0,
});
if let Some(real) = resolved(dir)
&& real != dir
{
armed.push(Arm {
dir: real,
period,
seen: 0,
});
}
}
fn resolved(dir: &Path) -> Option<PathBuf> {
let real = dir.canonicalize().ok()?;
let text = real.to_str()?;
Some(match text.strip_prefix(r"\\?\") {
Some(plain) => PathBuf::from(plain),
None => real,
})
}
pub(crate) fn disarm_sync_failure(dir: &Path) {
let mut armed = ARMED.lock();
let real = resolved(dir);
armed.retain(|a| a.dir != dir && Some(&a.dir) != real.as_ref());
}
pub(super) fn should_fail_sync(path: &Path) -> bool {
let mut armed = ARMED.lock();
let real = path.parent().and_then(resolved);
for arm in armed.iter_mut() {
let matched = path.starts_with(&arm.dir)
|| real.as_ref().is_some_and(|r| r.starts_with(&arm.dir));
if !matched {
continue;
}
if arm.period == 0 {
return true;
}
arm.seen += 1;
return arm.seen % arm.period != 0;
}
false
}
}
pub(crate) fn wal_filename(id: u64) -> String {
format!("wal_{:06}.log", id)
}
const RECORD_HEADER_LEN: usize = 5;
const RECORD_CHECKSUM_LEN: usize = 4;
fn record_len(payload_len: usize) -> usize {
RECORD_HEADER_LEN + payload_len + RECORD_CHECKSUM_LEN
}
fn encode_record(out: &mut Vec<u8>, record_type: u8, encode_payload: impl FnOnce(&mut Vec<u8>)) {
let header = out.len();
out.extend_from_slice(&[0u8; RECORD_HEADER_LEN]);
encode_payload(out);
let payload_start = header + RECORD_HEADER_LEN;
let len = (out.len() - payload_start) as u32;
out[header..payload_start - 1].copy_from_slice(&len.to_le_bytes());
out[payload_start - 1] = record_type;
let checksum = checksum::wal_record(len, record_type, &out[payload_start..]);
out.extend_from_slice(&checksum.to_le_bytes());
}
pub(crate) fn put_record_len(key: &[u8], value: &[u8]) -> usize {
record_len(put_payload_len(key, value))
}
pub(crate) fn encode_put_record(out: &mut Vec<u8>, key: &[u8], value: &[u8], seq: u64) {
encode_record(out, RECORD_PUT, |o| encode_put_payload(o, key, value, seq));
}
pub(crate) fn encode_op_record(out: &mut Vec<u8>, op: &WriteBatchOp, seq: u64) {
match op {
WriteBatchOp::Put { key, value } => encode_put_record(out, key, value, seq),
WriteBatchOp::Delete { key } => {
encode_record(out, RECORD_DELETE, |o| encode_delete_payload(o, key, seq));
}
WriteBatchOp::DeleteRange { start, end } => {
encode_record(out, RECORD_DELETE_RANGE, |o| {
encode_delete_range_payload(o, start, end, seq)
});
}
WriteBatchOp::Merge { key, operand } => {
encode_record(out, RECORD_MERGE, |o| {
encode_merge_payload(o, key, operand, seq)
});
}
}
}
pub(crate) fn encode_ops_batch_record(out: &mut Vec<u8>, ops: &[WriteBatchOp], base_seq: u64) {
encode_record(out, RECORD_BATCH, |o| {
o.extend_from_slice(&(ops.len() as u32).to_le_bytes());
for (i, op) in ops.iter().enumerate() {
encode_batch_op(o, op, base_seq + i as u64);
}
});
}
pub(crate) fn ops_record_len(ops: &[WriteBatchOp]) -> usize {
match ops {
[] => 0,
[op] => record_len(batch_op_payload_len(op)),
_ => record_len(batch_ops_payload_len(ops)),
}
}
pub(crate) fn encode_ops_record(out: &mut Vec<u8>, ops: &[WriteBatchOp], base_seq: u64) {
match ops {
[] => {}
[op] => encode_op_record(out, op, base_seq),
_ => encode_ops_batch_record(out, ops, base_seq),
}
}
fn encode_put_payload(out: &mut Vec<u8>, key: &[u8], value: &[u8], seq: u64) {
out.extend_from_slice(&(key.len() as u32).to_le_bytes());
out.extend_from_slice(key);
out.extend_from_slice(&(value.len() as u32).to_le_bytes());
out.extend_from_slice(value);
out.extend_from_slice(&seq.to_le_bytes());
}
fn encode_delete_payload(out: &mut Vec<u8>, key: &[u8], seq: u64) {
out.extend_from_slice(&(key.len() as u32).to_le_bytes());
out.extend_from_slice(key);
out.extend_from_slice(&seq.to_le_bytes());
}
fn encode_delete_range_payload(out: &mut Vec<u8>, start: &[u8], end: &[u8], seq: u64) {
out.extend_from_slice(&(start.len() as u32).to_le_bytes());
out.extend_from_slice(start);
out.extend_from_slice(&(end.len() as u32).to_le_bytes());
out.extend_from_slice(end);
out.extend_from_slice(&seq.to_le_bytes());
}
fn encode_merge_payload(out: &mut Vec<u8>, key: &[u8], operand: &[u8], seq: u64) {
out.extend_from_slice(&(key.len() as u32).to_le_bytes());
out.extend_from_slice(key);
out.extend_from_slice(&(operand.len() as u32).to_le_bytes());
out.extend_from_slice(operand);
out.extend_from_slice(&seq.to_le_bytes());
}
fn put_payload_len(key: &[u8], value: &[u8]) -> usize {
4 + key.len() + 4 + value.len() + 8
}
fn delete_payload_len(key: &[u8]) -> usize {
4 + key.len() + 8
}
fn delete_range_payload_len(start: &[u8], end: &[u8]) -> usize {
4 + start.len() + 4 + end.len() + 8
}
fn merge_payload_len(key: &[u8], operand: &[u8]) -> usize {
4 + key.len() + 4 + operand.len() + 8
}
fn batch_op_payload_len(op: &WriteBatchOp) -> usize {
match op {
WriteBatchOp::Put { key, value } => put_payload_len(key, value),
WriteBatchOp::Delete { key } => delete_payload_len(key),
WriteBatchOp::DeleteRange { start, end } => delete_range_payload_len(start, end),
WriteBatchOp::Merge { key, operand } => merge_payload_len(key, operand),
}
}
fn batch_payload_len(entry_payload_len: usize) -> usize {
1 + 4 + entry_payload_len
}
fn batch_ops_payload_len(ops: &[WriteBatchOp]) -> usize {
4 + ops
.iter()
.map(|op| batch_payload_len(batch_op_payload_len(op)))
.sum::<usize>()
}
fn encode_batch_header(out: &mut Vec<u8>, record_type: u8, payload_len: usize) {
out.push(record_type);
out.extend_from_slice(&(payload_len as u32).to_le_bytes());
}
fn encode_batch_op(out: &mut Vec<u8>, op: &WriteBatchOp, seq: u64) {
match op {
WriteBatchOp::Put { key, value } => {
encode_batch_header(out, RECORD_PUT, put_payload_len(key, value));
encode_put_payload(out, key, value, seq);
}
WriteBatchOp::Delete { key } => {
encode_batch_header(out, RECORD_DELETE, delete_payload_len(key));
encode_delete_payload(out, key, seq);
}
WriteBatchOp::DeleteRange { start, end } => {
encode_batch_header(
out,
RECORD_DELETE_RANGE,
delete_range_payload_len(start, end),
);
encode_delete_range_payload(out, start, end, seq);
}
WriteBatchOp::Merge { key, operand } => {
encode_batch_header(out, RECORD_MERGE, merge_payload_len(key, operand));
encode_merge_payload(out, key, operand, seq);
}
}
}
struct Frame<'a> {
record_type: u8,
data: &'a [u8],
stored_checksum: u32,
end: usize,
}
impl Frame<'_> {
fn checksum_matches(&self) -> bool {
let len = self.data.len() as u32;
self.stored_checksum == checksum::wal_record(len, self.record_type, self.data)
}
}
fn frame_at(bytes: &[u8], offset: usize) -> Option<Frame<'_>> {
let data_start = offset.checked_add(WAL_HEADER_LEN)?;
let header = bytes.get(offset..data_start)?;
let len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
let data_end = data_start.checked_add(len)?;
let end = data_end.checked_add(CHECKSUM_LEN)?;
let data = bytes.get(data_start..data_end)?;
let stored = bytes.get(data_end..end)?;
Some(Frame {
record_type: header[4],
data,
stored_checksum: u32::from_le_bytes([stored[0], stored[1], stored[2], stored[3]]),
end,
})
}
pub(super) fn classify_incomplete_record(
env: &dyn crate::env::Env,
path: &Path,
record_start: u64,
) -> io::Result<TailVerdict> {
let bytes = env.read(path)?;
let pos = usize::try_from(record_start)
.unwrap_or(usize::MAX)
.min(bytes.len());
end_of_log_or_corruption(path, &bytes, pos)?;
Ok(TailVerdict::discarded(&bytes, pos))
}
pub(super) fn classify_unusable_record(
env: &dyn crate::env::Env,
path: &Path,
record_start: u64,
) -> io::Result<TailVerdict> {
let bytes = env.read(path)?;
let pos = usize::try_from(record_start)
.unwrap_or(usize::MAX)
.min(bytes.len());
if !tail_is_unwritten(&bytes, pos) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"WAL checksum mismatch in {} at offset {pos}",
path.display()
),
));
}
Ok(TailVerdict::discarded(&bytes, pos))
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct TailVerdict {
pub(crate) offset: u64,
pub(crate) discarded_bytes: u64,
}
impl TailVerdict {
fn discarded(bytes: &[u8], pos: usize) -> Self {
let discarded_bytes = (bytes.len() - pos) as u64;
report_discarded_tail(bytes, discarded_bytes, pos);
Self {
offset: pos as u64,
discarded_bytes,
}
}
}
fn tail_is_unwritten(bytes: &[u8], pos: usize) -> bool {
bytes[pos..].iter().all(|b| *b == 0)
}
fn report_discarded_tail(_bytes: &[u8], discarded_bytes: u64, pos: usize) {
tracing::warn!(
offset = pos,
discarded_bytes,
"discarded an incomplete trailing WAL record left by a crash"
);
}
fn end_of_log_or_corruption(path: &Path, bytes: &[u8], pos: usize) -> io::Result<()> {
let discarded_bytes = bytes.len() - pos;
if let Some(next) = resync_after(bytes, pos) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{} is corrupt: the WAL record at offset {pos} runs past the end of the \
file, but a whole record follows it at offset {next}, so the \
{discarded_bytes} trailing byte(s) are damage rather than a torn write. \
Refusing to open rather than discard them.",
path.display()
),
));
}
tracing::warn!(
path = %path.display(),
offset = pos,
discarded_bytes,
"discarded an incomplete trailing WAL record left by a crash"
);
Ok(())
}
fn resync_after(bytes: &[u8], pos: usize) -> Option<usize> {
let scan_start = pos + 1;
if scan_start >= bytes.len() {
return None;
}
let mut tiles = vec![false; bytes.len() - scan_start];
let mut first = None;
for offset in (scan_start..bytes.len()).rev() {
let Some(frame) = frame_at(bytes, offset) else {
continue;
};
if frame.end != bytes.len() && !tiles[frame.end - scan_start] {
continue;
}
tiles[offset - scan_start] = true;
if matches!(
frame.record_type,
RECORD_PUT | RECORD_DELETE | RECORD_DELETE_RANGE | RECORD_MERGE | RECORD_BATCH
) && frame.checksum_matches()
{
first = Some(offset);
}
}
first
}
fn encode_wal_stamp() -> [u8; WAL_STAMP_LEN] {
let mut out = [0u8; WAL_STAMP_LEN];
out[0..4].copy_from_slice(&WAL_MAGIC);
out[4..6].copy_from_slice(&WAL_FORMAT_V1.to_le_bytes());
out[6..8].copy_from_slice(&0u16.to_le_bytes());
let checksum = checksum::wal_stamp(&WAL_MAGIC, WAL_FORMAT_V1, 0);
out[8..12].copy_from_slice(&checksum.to_le_bytes());
out
}
pub(super) fn validate_wal_stamp(bytes: &[u8]) -> io::Result<Option<usize>> {
if bytes.len() < WAL_STAMP_LEN {
return Ok(None);
}
if bytes[0..4] != WAL_MAGIC {
return Ok(None);
}
let format = u16::from_le_bytes([bytes[4], bytes[5]]);
let reserved = u16::from_le_bytes([bytes[6], bytes[7]]);
let stored = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
if stored != checksum::wal_stamp(&WAL_MAGIC, format, reserved) {
return Err(invalid_wal("WAL stamp checksum mismatch"));
}
if format > WAL_FORMAT_V1 {
return Err(invalid_wal(format!(
"WAL format {format} was written by a newer regolith than this build, \
which understands up to {WAL_FORMAT_V1}"
)));
}
Ok(Some(WAL_STAMP_LEN))
}
fn invalid_wal(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
pub(super) fn read_wal_header(reader: &mut impl Read) -> io::Result<Option<[u8; 5]>> {
let mut header = [0u8; 5];
let mut read = 0;
while read < header.len() {
match reader.read(&mut header[read..]) {
Ok(0) if read == 0 => return Ok(None),
Ok(0) => {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated WAL record header",
));
}
Ok(n) => read += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(Some(header))
}
pub(super) fn read_exact_or_truncated(
reader: &mut impl Read,
buf: &mut [u8],
message: &'static str,
) -> io::Result<()> {
reader.read_exact(buf).map_err(|e| {
if e.kind() == io::ErrorKind::UnexpectedEof {
io::Error::new(io::ErrorKind::UnexpectedEof, message)
} else {
e
}
})
}
pub(super) fn parse_put_record(data: &[u8]) -> io::Result<WalEntry> {
if data.len() < 16 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"put record too short",
));
}
let mut pos = 0;
let key_len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
if pos + key_len + 4 > data.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"put record key overflow",
));
}
let key = data[pos..pos + key_len].to_vec();
pos += key_len;
let value_len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
if pos + value_len + 8 > data.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"put record value overflow",
));
}
let value = data[pos..pos + value_len].to_vec();
pos += value_len;
let seq = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
Ok(WalEntry::Put { key, value, seq })
}
pub(super) fn parse_delete_record(data: &[u8]) -> io::Result<WalEntry> {
if data.len() < 12 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"delete record too short",
));
}
let key_len = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
if 4 + key_len + 8 > data.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"delete record key overflow",
));
}
let key = data[4..4 + key_len].to_vec();
let seq = u64::from_le_bytes(data[4 + key_len..4 + key_len + 8].try_into().unwrap());
Ok(WalEntry::Delete { key, seq })
}
pub(super) fn parse_delete_range_record(data: &[u8]) -> io::Result<WalEntry> {
if data.len() < 16 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"delete_range record too short",
));
}
let mut pos = 0;
let start_len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
if pos + start_len + 4 > data.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"delete_range record start overflow",
));
}
let start = data[pos..pos + start_len].to_vec();
pos += start_len;
let end_len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
if pos + end_len + 8 > data.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"delete_range record end overflow",
));
}
let end = data[pos..pos + end_len].to_vec();
pos += end_len;
let seq = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
Ok(WalEntry::DeleteRange { start, end, seq })
}
pub(super) fn parse_merge_record(data: &[u8]) -> io::Result<WalEntry> {
if data.len() < 16 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"merge record too short",
));
}
let mut pos = 0;
let key_len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
if pos + key_len + 4 > data.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"merge record key overflow",
));
}
let key = data[pos..pos + key_len].to_vec();
pos += key_len;
let operand_len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
if pos + operand_len + 8 > data.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"merge record operand overflow",
));
}
let operand = data[pos..pos + operand_len].to_vec();
pos += operand_len;
let seq = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
Ok(WalEntry::Merge { key, operand, seq })
}
pub(super) fn parse_batch_record(data: &[u8]) -> io::Result<Vec<WalEntry>> {
if data.len() < 4 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"batch record too short",
));
}
let mut pos = 0;
let count = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
let mut entries = Vec::new();
for _ in 0..count {
if pos + 5 > data.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"batch entry header overflow",
));
}
let record_type = data[pos];
pos += 1;
let payload_len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
if payload_len > data.len().saturating_sub(pos) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"batch entry payload overflow",
));
}
let payload = &data[pos..pos + payload_len];
pos += payload_len;
let entry = match record_type {
RECORD_PUT => parse_put_record(payload)?,
RECORD_DELETE => parse_delete_record(payload)?,
RECORD_DELETE_RANGE => parse_delete_range_record(payload)?,
RECORD_MERGE => parse_merge_record(payload)?,
RECORD_BATCH => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"nested batch records are not supported",
));
}
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown WAL batch entry type {record_type}"),
));
}
};
entries.push(entry);
}
if pos != data.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"batch record has trailing bytes",
));
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn new_wal(dir: &TempDir) -> (Wal, PathBuf) {
let path = dir.path().join("test.wal");
let wal = Wal::create(&path).unwrap();
(wal, path)
}
fn flip_byte(path: &Path, offset: usize) {
let mut bytes = fs::read(path).unwrap();
bytes[offset] ^= 0xFF;
fs::write(path, &bytes).unwrap();
}
fn put_data(key: &[u8], value: &[u8], seq: u64) -> Vec<u8> {
let mut d = Vec::with_capacity(4 + key.len() + 4 + value.len() + 8);
d.extend_from_slice(&(key.len() as u32).to_le_bytes());
d.extend_from_slice(key);
d.extend_from_slice(&(value.len() as u32).to_le_bytes());
d.extend_from_slice(value);
d.extend_from_slice(&seq.to_le_bytes());
d
}
fn append_raw_record(
f: &mut impl Write,
record_type: u8,
data: &[u8],
checksum_override: Option<u32>,
) {
let len = data.len() as u32;
let checksum =
checksum_override.unwrap_or_else(|| checksum::wal_record(len, record_type, data));
f.write_all(&len.to_le_bytes()).unwrap();
f.write_all(&[record_type]).unwrap();
f.write_all(data).unwrap();
f.write_all(&checksum.to_le_bytes()).unwrap();
}
#[test]
fn test_wal_write_and_replay() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.wal");
{
let mut wal = Wal::create(&path).unwrap();
wal.append_put(b"key1", b"value1", 1).unwrap();
wal.append_delete(b"key2", 2).unwrap();
wal.append_put(b"key3", b"value3", 3).unwrap();
}
let entries = Wal::replay(&path).unwrap();
assert_eq!(entries.len(), 3);
match &entries[0] {
WalEntry::Put { key, value, seq } => {
assert_eq!(key, b"key1");
assert_eq!(value, b"value1");
assert_eq!(*seq, 1);
}
_ => panic!("expected put"),
}
match &entries[1] {
WalEntry::Delete { key, seq } => {
assert_eq!(key, b"key2");
assert_eq!(*seq, 2);
}
_ => panic!("expected delete"),
}
}
#[test]
fn test_wal_filename() {
assert_eq!(wal_filename(1), "wal_000001.log");
assert_eq!(wal_filename(42), "wal_000042.log");
}
#[test]
fn put_record_round_trips() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"k", b"v", 7).unwrap();
drop(wal);
let entries = Wal::replay(&path).unwrap();
match entries.as_slice() {
[WalEntry::Put { key, value, seq: 7 }] => {
assert_eq!(key, b"k");
assert_eq!(value, b"v");
}
_ => panic!("expected a single put at seq=7, got {}", entries.len()),
}
}
#[test]
fn delete_record_round_trips() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_delete(b"gone", 11).unwrap();
drop(wal);
let entries = Wal::replay(&path).unwrap();
match entries.as_slice() {
[WalEntry::Delete { key, seq: 11 }] => assert_eq!(key, b"gone"),
_ => panic!("expected a single delete"),
}
}
#[test]
fn delete_range_record_round_trips() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_delete_range(b"aaa", b"zzz", 5).unwrap();
drop(wal);
let entries = Wal::replay(&path).unwrap();
match entries.as_slice() {
[WalEntry::DeleteRange { start, end, seq: 5 }] => {
assert_eq!(start, b"aaa");
assert_eq!(end, b"zzz");
}
_ => panic!("expected a single delete_range"),
}
}
#[test]
fn merge_record_round_trips() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_merge(b"counter", b"+3", 99).unwrap();
drop(wal);
let entries = Wal::replay(&path).unwrap();
match entries.as_slice() {
[
WalEntry::Merge {
key,
operand,
seq: 99,
},
] => {
assert_eq!(key, b"counter");
assert_eq!(operand, b"+3");
}
_ => panic!("expected a single merge"),
}
}
#[test]
fn all_record_types_replay_in_order() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"p", b"1", 1).unwrap();
wal.append_delete(b"d", 2).unwrap();
wal.append_delete_range(b"ra", b"rb", 3).unwrap();
wal.append_merge(b"m", b"op", 4).unwrap();
drop(wal);
let entries = Wal::replay(&path).unwrap();
assert_eq!(entries.len(), 4);
assert!(matches!(entries[0], WalEntry::Put { seq: 1, .. }));
assert!(matches!(entries[1], WalEntry::Delete { seq: 2, .. }));
assert!(matches!(entries[2], WalEntry::DeleteRange { seq: 3, .. }));
assert!(matches!(entries[3], WalEntry::Merge { seq: 4, .. }));
}
#[test]
fn batch_record_replays_all_entries_in_order() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
let ops = vec![
WriteBatchOp::Put {
key: b"p".to_vec(),
value: b"1".to_vec(),
},
WriteBatchOp::Delete { key: b"d".to_vec() },
WriteBatchOp::DeleteRange {
start: b"ra".to_vec(),
end: b"rb".to_vec(),
},
WriteBatchOp::Merge {
key: b"m".to_vec(),
operand: b"op".to_vec(),
},
];
let mut record = Vec::new();
encode_ops_batch_record(&mut record, &ops, 10);
wal.append_group(&record).unwrap();
drop(wal);
assert_eq!(
Wal::replay(&path).unwrap(),
vec![
WalEntry::Put {
key: b"p".to_vec(),
value: b"1".to_vec(),
seq: 10,
},
WalEntry::Delete {
key: b"d".to_vec(),
seq: 11,
},
WalEntry::DeleteRange {
start: b"ra".to_vec(),
end: b"rb".to_vec(),
seq: 12,
},
WalEntry::Merge {
key: b"m".to_vec(),
operand: b"op".to_vec(),
seq: 13,
},
]
);
}
#[test]
fn empty_ops_encodes_and_appends_nothing() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
let mut record = Vec::new();
encode_ops_record(&mut record, &[], 1);
assert!(record.is_empty());
wal.append_group(&record).unwrap();
assert_eq!(
wal.offset(),
WAL_STAMP_LEN as u64,
"an empty group appends nothing past the stamp"
);
drop(wal);
assert!(Wal::replay(&path).unwrap().is_empty());
}
#[test]
fn replay_empty_file_returns_no_entries() {
let dir = TempDir::new().unwrap();
let (wal, path) = new_wal(&dir);
drop(wal);
assert!(Wal::replay(&path).unwrap().is_empty());
}
#[test]
fn round_trip_empty_key_and_value() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"", b"", 0).unwrap();
drop(wal);
match Wal::replay(&path).unwrap().as_slice() {
[WalEntry::Put { key, value, seq: 0 }] => {
assert!(key.is_empty());
assert!(value.is_empty());
}
other => panic!("expected empty-key/value put, got {} entries", other.len()),
}
}
#[test]
fn round_trip_large_value() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
let big = vec![0xAB; 1 << 20];
wal.append_put(b"k", &big, 1).unwrap();
drop(wal);
match Wal::replay(&path).unwrap().as_slice() {
[WalEntry::Put { key, value, seq: 1 }] => {
assert_eq!(key, b"k");
assert_eq!(value.len(), 1 << 20);
assert!(value.iter().all(|&b| b == 0xAB));
}
_ => panic!("expected single large put"),
}
}
#[test]
fn round_trip_boundary_seq_numbers() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"a", b"1", 0).unwrap();
wal.append_put(b"b", b"2", u64::MAX).unwrap();
drop(wal);
let entries = Wal::replay(&path).unwrap();
assert_eq!(entries.len(), 2);
let seqs: Vec<u64> = entries
.iter()
.map(|e| match e {
WalEntry::Put { seq, .. } => *seq,
_ => panic!("expected put"),
})
.collect();
assert_eq!(seqs, vec![0, u64::MAX]);
}
#[test]
fn replay_many_records() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
for i in 0..1000u64 {
wal.append_put(format!("k{:06}", i).as_bytes(), b"v", i)
.unwrap();
}
drop(wal);
let entries = Wal::replay(&path).unwrap();
assert_eq!(entries.len(), 1000);
for (i, entry) in entries.iter().enumerate() {
match entry {
WalEntry::Put { key, seq, .. } => {
assert_eq!(key, format!("k{:06}", i).as_bytes());
assert_eq!(*seq, i as u64);
}
_ => panic!("expected put at index {}", i),
}
}
}
#[test]
fn sync_persists_records_across_drop() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.wal");
{
let mut wal = Wal::create(&path).unwrap();
wal.append_put(b"k", b"v", 1).unwrap();
wal.sync_data().unwrap();
}
let entries = Wal::replay(&path).unwrap();
assert_eq!(entries.len(), 1);
}
#[test]
fn sync_syncs_parent_dir_once() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.wal");
let mut wal = Wal::create(&path).unwrap();
wal.append_put(b"k", b"v", 1).unwrap();
let mut sync_count = 0;
wal.sync_with_parent_sync(|sync_path| {
assert_eq!(sync_path, path.as_path());
sync_count += 1;
Ok(())
})
.unwrap();
wal.sync_with_parent_sync(|_| {
sync_count += 1;
Ok(())
})
.unwrap();
assert_eq!(sync_count, 1);
}
#[test]
fn sync_retries_parent_dir_sync_after_error() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.wal");
let mut wal = Wal::create(&path).unwrap();
wal.append_put(b"k", b"v", 1).unwrap();
let mut sync_count = 0;
let err = match wal.sync_with_parent_sync(|_| {
sync_count += 1;
Err(io::Error::other("injected parent sync failure"))
}) {
Ok(_) => panic!("expected parent sync failure"),
Err(err) => err,
};
assert_eq!(err.kind(), io::ErrorKind::Other);
assert_eq!(err.to_string(), "injected parent sync failure");
wal.sync_with_parent_sync(|sync_path| {
assert_eq!(sync_path, path.as_path());
sync_count += 1;
Ok(())
})
.unwrap();
assert_eq!(sync_count, 2);
}
#[test]
fn create_truncates_prior_contents() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.wal");
{
let mut wal = Wal::create(&path).unwrap();
wal.append_put(b"old", b"v", 1).unwrap();
}
{
let mut wal = Wal::create(&path).unwrap();
wal.append_put(b"new", b"v", 2).unwrap();
}
match Wal::replay(&path).unwrap().as_slice() {
[WalEntry::Put { key, seq: 2, .. }] => assert_eq!(key, b"new"),
other => panic!("old contents leaked: got {} entries", other.len()),
}
}
#[test]
fn remove_deletes_underlying_file() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"k", b"v", 1).unwrap();
drop(wal);
assert!(path.exists());
Wal::remove(&path).unwrap();
assert!(!path.exists());
}
#[test]
fn path_returns_creation_path() {
let dir = TempDir::new().unwrap();
let (wal, path) = new_wal(&dir);
assert_eq!(wal.path(), path);
}
#[test]
fn a_fresh_log_begins_with_the_rego_stamp() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"k", b"v", 1).unwrap();
wal.sync_data().unwrap();
drop(wal);
let bytes = fs::read(&path).unwrap();
assert_eq!(&bytes[0..4], b"REGO", "the log is not stamped");
assert_eq!(
u16::from_le_bytes([bytes[4], bytes[5]]),
WAL_FORMAT_V1,
"the stamp names the format this build writes"
);
assert_eq!(
validate_wal_stamp(&bytes).unwrap(),
Some(WAL_STAMP_LEN),
"the stamp validates against its own checksum"
);
}
#[test]
fn the_stamp_cannot_be_confused_with_a_record_length() {
let as_len = u32::from_le_bytes(WAL_MAGIC);
assert!(
as_len > MAX_RECORD_LEN,
"REGO as a length ({as_len}) must exceed MAX_RECORD_LEN ({MAX_RECORD_LEN})"
);
}
#[test]
fn a_log_from_a_newer_format_is_refused_rather_than_guessed_at() {
let mut stamp = encode_wal_stamp();
let future = WAL_FORMAT_V1 + 1;
stamp[4..6].copy_from_slice(&future.to_le_bytes());
let checksum = checksum::wal_stamp(&WAL_MAGIC, future, 0);
stamp[8..12].copy_from_slice(&checksum.to_le_bytes());
let err = validate_wal_stamp(&stamp).expect_err("a newer format must not be parsed");
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert!(
err.to_string().contains("newer regolith"),
"the error must say why: {err}"
);
}
#[test]
fn a_corrupt_stamp_is_damage_not_a_record_stream() {
let mut stamp = encode_wal_stamp();
stamp[8] ^= 0xFF;
let err = validate_wal_stamp(&stamp).expect_err("a bad checksum must not pass");
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn a_file_that_is_not_a_stamped_log_yields_no_records() {
for bytes in [
&b"this is not a write-ahead log at all"[..],
&[0xFFu8; 64][..],
&[0x00u8; 64][..],
] {
assert_eq!(
validate_wal_stamp(bytes).expect("an unstamped log is discardable, not an error"),
None,
"unstamped bytes must report no stamp so the caller can record the discard",
);
}
}
#[test]
fn a_damaged_but_present_stamp_is_still_refused() {
let mut stamp = encode_wal_stamp();
stamp[8] ^= 0xFF;
let err = validate_wal_stamp(&stamp).expect_err("a corrupt checksum must not pass");
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn a_crash_before_the_stamp_reached_disk_reads_as_an_empty_log() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("torn-stamp.wal");
for cut in 0..WAL_STAMP_LEN {
fs::write(&path, &encode_wal_stamp()[..cut]).unwrap();
assert!(
Wal::replay(&path).unwrap().is_empty(),
"a stamp torn at {cut} must read as an empty log"
);
}
}
#[test]
fn offset_tracks_every_appended_byte() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
assert_eq!(
wal.offset(),
WAL_STAMP_LEN as u64,
"a fresh log holds its stamp"
);
wal.append_put(b"k", b"v", 1).unwrap();
let after_one = wal.offset();
assert_eq!(
after_one as usize,
WAL_STAMP_LEN + put_record_len(b"k", b"v")
);
wal.append_put(b"k2", b"v2", 2).unwrap();
assert_eq!(
wal.offset() as usize,
after_one as usize + put_record_len(b"k2", b"v2")
);
wal.sync_data().unwrap();
assert_eq!(fs::metadata(&path).unwrap().len(), wal.offset());
}
#[test]
fn a_group_of_records_replays_exactly_like_individual_appends() {
let dir = TempDir::new().unwrap();
let grouped_path = dir.path().join("grouped.wal");
let individual_path = dir.path().join("individual.wal");
let ops = vec![
WriteBatchOp::Put {
key: b"a".to_vec(),
value: b"1".to_vec(),
},
WriteBatchOp::Delete { key: b"b".to_vec() },
];
{
let mut wal = Wal::create(&grouped_path).unwrap();
let mut group = Vec::new();
encode_put_record(&mut group, b"solo", b"v", 1);
encode_ops_record(&mut group, &ops, 2);
wal.append_group(&group).unwrap();
}
{
let mut wal = Wal::create(&individual_path).unwrap();
wal.append_put(b"solo", b"v", 1).unwrap();
let mut record = Vec::new();
encode_ops_batch_record(&mut record, &ops, 2);
wal.append_group(&record).unwrap();
}
assert_eq!(
fs::read(&grouped_path).unwrap(),
fs::read(&individual_path).unwrap(),
"grouping must not change a single on-disk byte"
);
assert_eq!(
Wal::replay(&grouped_path).unwrap(),
Wal::replay(&individual_path).unwrap()
);
assert_eq!(Wal::replay(&grouped_path).unwrap().len(), 3);
}
#[test]
fn rollback_discards_a_group_and_leaves_the_log_replayable() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"keep", b"v", 1).unwrap();
let good = wal.offset();
let mut group = Vec::new();
encode_put_record(&mut group, b"discard", b"v", 2);
wal.append_group(&group).unwrap();
assert!(wal.offset() > good);
wal.rollback_to(good).unwrap();
assert_eq!(wal.offset(), good);
wal.append_put(b"after", b"v", 3).unwrap();
wal.sync_data().unwrap();
assert_eq!(fs::metadata(&path).unwrap().len(), wal.offset());
let entries = Wal::replay(&path).unwrap();
assert_eq!(
entries,
vec![
WalEntry::Put {
key: b"keep".to_vec(),
value: b"v".to_vec(),
seq: 1,
},
WalEntry::Put {
key: b"after".to_vec(),
value: b"v".to_vec(),
seq: 3,
},
]
);
}
#[test]
fn rollback_of_a_torn_partial_group_leaves_no_record_behind() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
let good = wal.offset();
let mut group = Vec::new();
encode_put_record(&mut group, b"torn", b"value", 7);
wal.append_group(&group[..group.len() - 3]).unwrap();
assert!(
Wal::replay(&path).unwrap().is_empty(),
"a torn tail must not replay as an entry"
);
wal.rollback_to(good).unwrap();
assert!(Wal::replay(&path).unwrap().is_empty());
}
#[test]
fn record_len_matches_the_bytes_each_encoder_emits() {
let mut out = Vec::new();
encode_put_record(&mut out, b"key", b"value", 4);
assert_eq!(out.len(), put_record_len(b"key", b"value"));
let ops = vec![
WriteBatchOp::Merge {
key: b"m".to_vec(),
operand: b"o".to_vec(),
},
WriteBatchOp::DeleteRange {
start: b"s".to_vec(),
end: b"e".to_vec(),
},
];
let mut out = Vec::new();
encode_ops_record(&mut out, &ops, 9);
assert_eq!(out.len(), ops_record_len(&ops));
let single = vec![WriteBatchOp::Merge {
key: b"m".to_vec(),
operand: b"o".to_vec(),
}];
let mut out = Vec::new();
encode_ops_record(&mut out, &single, 9);
assert_eq!(out.len(), ops_record_len(&single));
}
#[test]
fn replay_errors_on_trailing_checksum_flip() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"good", b"v", 1).unwrap();
wal.append_put(b"torn", b"v", 2).unwrap();
drop(wal);
let len = fs::metadata(&path).unwrap().len() as usize;
flip_byte(&path, len - 1);
let kind = match Wal::replay(&path) {
Err(e) => e.kind(),
Ok(v) => panic!("expected checksum error, got {} entries", v.len()),
};
assert_eq!(kind, io::ErrorKind::InvalidData);
}
#[test]
fn replay_errors_on_trailing_data_byte_flip() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"good", b"v", 1).unwrap();
wal.append_put(b"torn", b"v", 2).unwrap();
drop(wal);
let len = fs::metadata(&path).unwrap().len() as usize;
flip_byte(&path, len - 6);
let kind = match Wal::replay(&path) {
Err(e) => e.kind(),
Ok(v) => panic!("expected checksum error, got {} entries", v.len()),
};
assert_eq!(kind, io::ErrorKind::InvalidData);
}
#[test]
fn record_checksum_covers_header_fields() {
let data = put_data(b"k", b"v", 1);
let len = data.len() as u32;
let baseline = checksum::wal_record(len, RECORD_PUT, &data);
assert_ne!(baseline, checksum::wal_record(len + 1, RECORD_PUT, &data));
assert_ne!(baseline, checksum::wal_record(len, RECORD_DELETE, &data));
}
#[test]
fn replay_errors_on_record_type_header_flip() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"k", b"v", 1).unwrap();
drop(wal);
flip_byte(&path, 4);
let kind = match Wal::replay(&path) {
Err(e) => e.kind(),
Ok(v) => panic!("expected checksum error, got {} entries", v.len()),
};
assert_eq!(kind, io::ErrorKind::InvalidData);
}
#[test]
fn replay_stops_at_a_truncated_trailing_header() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"good", b"v", 1).unwrap();
drop(wal);
let mut bytes = fs::read(&path).unwrap();
bytes.extend_from_slice(&[0xFF, 0xFF]);
fs::write(&path, &bytes).unwrap();
assert_eq!(
Wal::replay(&path).unwrap(),
vec![WalEntry::Put {
key: b"good".to_vec(),
value: b"v".to_vec(),
seq: 1,
}]
);
}
#[test]
fn replay_treats_a_length_beyond_the_file_as_a_torn_tail() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad.wal");
let mut f = File::create(&path).unwrap();
f.write_all(&1000u32.to_le_bytes()).unwrap(); f.write_all(&[RECORD_PUT]).unwrap(); f.sync_all().unwrap();
assert!(Wal::replay(&path).unwrap().is_empty());
}
#[test]
fn replay_rejects_a_length_beyond_the_file_when_whole_records_follow() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
for i in 0..4u64 {
wal.append_put(format!("k{i}").as_bytes(), b"v", i).unwrap();
}
wal.sync_data().unwrap();
drop(wal);
let mut bytes = fs::read(&path).unwrap();
let second = frame_at(&bytes, WAL_STAMP_LEN).unwrap().end;
bytes[second..second + 4].copy_from_slice(&u32::MAX.to_le_bytes());
fs::write(&path, &bytes).unwrap();
let err = match Wal::replay(&path) {
Err(e) => e,
Ok(v) => panic!("expected corruption, got {} entries", v.len()),
};
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
let message = err.to_string();
assert!(message.contains("test.wal"), "{message}");
assert!(message.contains(&second.to_string()), "{message}");
}
#[test]
fn replay_of_a_self_similar_torn_tail_stays_linear() {
const TAIL: usize = 4 << 20;
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
wal.append_put(b"good", b"v", 1).unwrap();
wal.sync_data().unwrap();
drop(wal);
let mut bytes = fs::read(&path).unwrap();
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
bytes.push(RECORD_PUT);
bytes.extend(
[0x00, 0x00, 0x10, 0x00, RECORD_PUT]
.iter()
.cycle()
.take(TAIL),
);
fs::write(&path, &bytes).unwrap();
let start = std::time::Instant::now();
let entries = Wal::replay(&path).unwrap();
let elapsed = start.elapsed();
assert_eq!(
entries,
vec![WalEntry::Put {
key: b"good".to_vec(),
value: b"v".to_vec(),
seq: 1,
}]
);
assert!(
elapsed < std::time::Duration::from_secs(20),
"replaying a {TAIL}-byte torn tail took {elapsed:?}, which is not one pass over it",
);
}
#[test]
fn replay_keeps_every_whole_record_before_a_cut_at_any_offset() {
let dir = TempDir::new().unwrap();
let (mut wal, path) = new_wal(&dir);
for i in 0..4u64 {
wal.append_put(format!("k{i}").as_bytes(), b"v", i).unwrap();
}
wal.sync_data().unwrap();
drop(wal);
let full = fs::read(&path).unwrap();
let mut boundaries = vec![WAL_STAMP_LEN];
while let Some(frame) = frame_at(&full, *boundaries.last().unwrap()) {
boundaries.push(frame.end);
}
assert_eq!(boundaries.len(), 5, "four records tile the file");
for cut in WAL_STAMP_LEN..=full.len() {
fs::write(&path, &full[..cut]).unwrap();
let whole = boundaries.iter().filter(|b| **b <= cut).count() - 1;
let entries = Wal::replay(&path)
.unwrap_or_else(|e| panic!("a cut at {cut} is a torn tail, not corruption: {e}"));
assert_eq!(entries.len(), whole, "cut at {cut}");
}
}
#[test]
fn replay_errors_on_unknown_record_type() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("mixed.wal");
let mut f = File::create(&path).unwrap();
let unknown_payload = b"opaque bytes".to_vec();
append_raw_record(&mut f, 0xEF, &unknown_payload, None);
let pd = put_data(b"after", b"ok", 42);
append_raw_record(&mut f, RECORD_PUT, &pd, None);
f.sync_all().unwrap();
let kind = match Wal::replay(&path) {
Err(e) => e.kind(),
Ok(v) => panic!("expected unknown-type error, got {} entries", v.len()),
};
assert_eq!(kind, io::ErrorKind::InvalidData);
}
#[test]
fn replay_rejects_malformed_batch_entry_payload() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad_batch.wal");
let mut data = Vec::new();
data.extend_from_slice(&1u32.to_le_bytes()); data.push(RECORD_PUT);
data.extend_from_slice(&100u32.to_le_bytes()); data.extend_from_slice(&[0xAA, 0xBB]);
let mut f = File::create(&path).unwrap();
f.write_all(&encode_wal_stamp()).unwrap();
append_raw_record(&mut f, RECORD_BATCH, &data, None);
f.sync_all().unwrap();
let kind = match Wal::replay(&path) {
Err(e) => e.kind(),
Ok(v) => panic!("expected malformed-batch error, got {} entries", v.len()),
};
assert_eq!(kind, io::ErrorKind::InvalidData);
}
#[test]
fn replay_rejects_batch_trailing_bytes() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("trailing_batch.wal");
let mut data = Vec::new();
data.extend_from_slice(&0u32.to_le_bytes());
data.push(0xFF);
let mut f = File::create(&path).unwrap();
f.write_all(&encode_wal_stamp()).unwrap();
append_raw_record(&mut f, RECORD_BATCH, &data, None);
f.sync_all().unwrap();
let kind = match Wal::replay(&path) {
Err(e) => e.kind(),
Ok(v) => panic!(
"expected batch trailing-byte error, got {} entries",
v.len()
),
};
assert_eq!(kind, io::ErrorKind::InvalidData);
}
#[test]
fn replay_of_nonexistent_path_errors() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("never_created.wal");
let kind = match Wal::replay(&path) {
Err(e) => e.kind(),
Ok(v) => panic!("expected error, got {} entries", v.len()),
};
assert_eq!(kind, io::ErrorKind::NotFound);
}
#[test]
fn parse_put_rejects_short_data() {
assert!(parse_put_record(&[0u8; 15]).is_err());
}
#[test]
fn parse_put_rejects_key_len_overflow() {
let mut data = Vec::new();
data.extend_from_slice(&100u32.to_le_bytes());
data.extend_from_slice(&[0u8; 2]);
data.resize(16, 0);
assert!(parse_put_record(&data).is_err());
}
#[test]
fn parse_delete_rejects_short_data() {
assert!(parse_delete_record(&[0u8; 11]).is_err());
}
#[test]
fn parse_delete_rejects_key_len_overflow() {
let mut data = Vec::new();
data.extend_from_slice(&100u32.to_le_bytes());
data.resize(12, 0);
assert!(parse_delete_record(&data).is_err());
}
#[test]
fn parse_delete_range_rejects_short_data() {
assert!(parse_delete_range_record(&[0u8; 15]).is_err());
}
#[test]
fn parse_delete_range_rejects_start_len_overflow() {
let mut data = Vec::new();
data.extend_from_slice(&100u32.to_le_bytes());
data.resize(16, 0);
assert!(parse_delete_range_record(&data).is_err());
}
#[test]
fn parse_delete_range_rejects_end_len_overflow() {
let mut data = Vec::new();
data.extend_from_slice(&1u32.to_le_bytes());
data.push(b'k');
data.extend_from_slice(&100u32.to_le_bytes());
data.resize(16, 0);
assert!(parse_delete_range_record(&data).is_err());
}
#[test]
fn parse_merge_rejects_short_data() {
assert!(parse_merge_record(&[0u8; 15]).is_err());
}
#[test]
fn parse_merge_rejects_key_len_overflow() {
let mut data = Vec::new();
data.extend_from_slice(&100u32.to_le_bytes());
data.resize(16, 0);
assert!(parse_merge_record(&data).is_err());
}
#[test]
fn parse_merge_rejects_operand_len_overflow() {
let mut data = Vec::new();
data.extend_from_slice(&1u32.to_le_bytes());
data.push(b'k');
data.extend_from_slice(&100u32.to_le_bytes());
data.resize(16, 0);
assert!(parse_merge_record(&data).is_err());
}
}