use crate::error::{Error, Result};
use crate::storage::write_engine::mutation::{
CellOperation, ClusteringKey, Mutation, PartitionKey, PartitionTombstone, RangeTombstone,
TableId,
};
use crc32fast::Hasher;
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
const MAX_ENTRY_LENGTH: u32 = 16 * 1024 * 1024;
#[derive(Debug, Clone, Default)]
pub struct RecoveryReport {
pub mutations: Vec<Mutation>,
pub corrupt_entries: usize,
pub stopped_early: bool,
pub bytes_skipped: u64,
}
impl RecoveryReport {
pub fn is_clean(&self) -> bool {
self.corrupt_entries == 0 && !self.stopped_early
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WalStop {
CleanEof,
TornTail,
Corruption,
}
#[derive(Debug)]
pub enum TruncateError {
BeforeMutation(Error),
AfterMutation(Error),
}
impl TruncateError {
pub fn into_inner(self) -> Error {
match self {
TruncateError::BeforeMutation(e) | TruncateError::AfterMutation(e) => e,
}
}
}
impl std::fmt::Display for TruncateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TruncateError::BeforeMutation(e) => {
write!(f, "WAL truncate failed before mutation (WAL intact): {e}")
}
TruncateError::AfterMutation(e) => write!(
f,
"WAL truncate failed after mutation (WAL contents already zeroed): {e}"
),
}
}
}
impl std::error::Error for TruncateError {}
#[cfg(unix)]
pub(crate) fn sync_directory(dir: &Path) -> Result<()> {
let dir_file = File::open(dir)
.map_err(|e| Error::Storage(format!("Failed to open directory for sync: {}", e)))?;
dir_file
.sync_all()
.map_err(|e| Error::Storage(format!("Failed to sync directory: {}", e)))?;
Ok(())
}
#[cfg(not(unix))]
pub(crate) fn sync_directory(_dir: &Path) -> Result<()> {
Ok(())
}
fn validate_wal_directory(dir: &Path) -> Result<PathBuf> {
if !dir.exists() {
return Err(Error::InvalidPath(format!(
"WAL directory does not exist: {:?}",
dir
)));
}
if !dir.is_dir() {
return Err(Error::InvalidPath(format!(
"WAL path is not a directory: {:?}",
dir
)));
}
let canonical = dir
.canonicalize()
.map_err(|e| Error::InvalidPath(format!("Failed to canonicalize WAL directory: {}", e)))?;
let path_str = canonical.to_string_lossy();
if path_str.chars().any(|c| c.is_control()) {
return Err(Error::InvalidPath(
"WAL directory path contains control characters".to_string(),
));
}
Ok(canonical)
}
#[cfg(unix)]
fn set_secure_permissions(file: &File) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = file
.metadata()
.map_err(|e| Error::Storage(format!("Failed to read file metadata: {}", e)))?
.permissions();
perms.set_mode(0o600);
file.set_permissions(perms)
.map_err(|e| Error::Storage(format!("Failed to set file permissions: {}", e)))?;
Ok(())
}
#[cfg(not(unix))]
fn set_secure_permissions(_file: &File) -> Result<()> {
Ok(())
}
#[derive(serde::Serialize, serde::Deserialize)]
enum LegacyCellOperation {
Write {
column: String,
value: crate::types::Value,
},
WriteWithTtl {
column: String,
value: crate::types::Value,
ttl_seconds: u32,
},
Delete {
column: String,
},
DeleteRow,
WriteComplexElement {
column: String,
cell_path: Vec<u8>,
value: Option<crate::types::Value>,
timestamp_micros: i64,
ttl_seconds: Option<u32>,
local_deletion_time: Option<i32>,
is_deleted: bool,
},
ComplexDeletion {
column: String,
marked_for_delete_at: i64,
local_deletion_time: i32,
},
}
impl From<LegacyCellOperation> for CellOperation {
fn from(op: LegacyCellOperation) -> Self {
match op {
LegacyCellOperation::Write { column, value } => CellOperation::Write { column, value },
LegacyCellOperation::WriteWithTtl {
column,
value,
ttl_seconds,
} => CellOperation::WriteWithTtl {
column,
value,
ttl_seconds,
local_deletion_time: None,
},
LegacyCellOperation::Delete { column } => CellOperation::Delete {
column,
local_deletion_time: None,
},
LegacyCellOperation::DeleteRow => CellOperation::DeleteRow,
LegacyCellOperation::WriteComplexElement {
column,
cell_path,
value,
timestamp_micros,
ttl_seconds,
local_deletion_time,
is_deleted,
} => CellOperation::WriteComplexElement {
column,
cell_path,
value,
timestamp_micros,
ttl_seconds,
local_deletion_time,
is_deleted,
},
LegacyCellOperation::ComplexDeletion {
column,
marked_for_delete_at,
local_deletion_time,
} => CellOperation::ComplexDeletion {
column,
marked_for_delete_at,
local_deletion_time,
},
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct LegacyMutation {
table: TableId,
partition_key: PartitionKey,
clustering_key: Option<ClusteringKey>,
operations: Vec<LegacyCellOperation>,
timestamp_micros: i64,
ttl_seconds: Option<u32>,
partition_tombstone: Option<PartitionTombstone>,
range_tombstones: Vec<RangeTombstone>,
}
impl From<LegacyMutation> for Mutation {
fn from(m: LegacyMutation) -> Self {
Mutation {
table: m.table,
partition_key: m.partition_key,
clustering_key: m.clustering_key,
operations: m.operations.into_iter().map(CellOperation::from).collect(),
timestamp_micros: m.timestamp_micros,
ttl_seconds: m.ttl_seconds,
partition_tombstone: m.partition_tombstone,
range_tombstones: m.range_tombstones,
local_deletion_time: None,
row_tombstone: None,
cell_write_timestamps: None,
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct LegacyMutationWithLdt {
table: TableId,
partition_key: PartitionKey,
clustering_key: Option<ClusteringKey>,
operations: Vec<LegacyCellOperation>,
timestamp_micros: i64,
ttl_seconds: Option<u32>,
partition_tombstone: Option<PartitionTombstone>,
range_tombstones: Vec<RangeTombstone>,
local_deletion_time: Option<i32>,
}
impl From<LegacyMutationWithLdt> for Mutation {
fn from(m: LegacyMutationWithLdt) -> Self {
Mutation {
table: m.table,
partition_key: m.partition_key,
clustering_key: m.clustering_key,
operations: m.operations.into_iter().map(CellOperation::from).collect(),
timestamp_micros: m.timestamp_micros,
ttl_seconds: m.ttl_seconds,
partition_tombstone: m.partition_tombstone,
range_tombstones: m.range_tombstones,
local_deletion_time: m.local_deletion_time,
row_tombstone: None,
cell_write_timestamps: None,
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct PreRowTombstoneMutation {
table: TableId,
partition_key: PartitionKey,
clustering_key: Option<ClusteringKey>,
operations: Vec<PreCellLdtWriteTtlCellOperation>,
timestamp_micros: i64,
ttl_seconds: Option<u32>,
partition_tombstone: Option<PartitionTombstone>,
range_tombstones: Vec<RangeTombstone>,
local_deletion_time: Option<i32>,
}
impl From<PreRowTombstoneMutation> for Mutation {
fn from(m: PreRowTombstoneMutation) -> Self {
Mutation {
table: m.table,
partition_key: m.partition_key,
clustering_key: m.clustering_key,
operations: m.operations.into_iter().map(CellOperation::from).collect(),
timestamp_micros: m.timestamp_micros,
ttl_seconds: m.ttl_seconds,
partition_tombstone: m.partition_tombstone,
range_tombstones: m.range_tombstones,
local_deletion_time: m.local_deletion_time,
row_tombstone: None,
cell_write_timestamps: None,
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct PreCellWriteTimestampsMutation {
table: TableId,
partition_key: PartitionKey,
clustering_key: Option<ClusteringKey>,
operations: Vec<PreCellLdtWriteTtlCellOperation>,
timestamp_micros: i64,
ttl_seconds: Option<u32>,
partition_tombstone: Option<PartitionTombstone>,
range_tombstones: Vec<RangeTombstone>,
local_deletion_time: Option<i32>,
row_tombstone: Option<(i64, i32)>,
}
impl From<PreCellWriteTimestampsMutation> for Mutation {
fn from(m: PreCellWriteTimestampsMutation) -> Self {
Mutation {
table: m.table,
partition_key: m.partition_key,
clustering_key: m.clustering_key,
operations: m.operations.into_iter().map(CellOperation::from).collect(),
timestamp_micros: m.timestamp_micros,
ttl_seconds: m.ttl_seconds,
partition_tombstone: m.partition_tombstone,
range_tombstones: m.range_tombstones,
local_deletion_time: m.local_deletion_time,
row_tombstone: m.row_tombstone,
cell_write_timestamps: None,
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
enum PreCellLdtWriteTtlCellOperation {
Write {
column: String,
value: crate::types::Value,
},
WriteWithTtl {
column: String,
value: crate::types::Value,
ttl_seconds: u32,
},
Delete {
column: String,
local_deletion_time: Option<i32>,
},
DeleteRow,
WriteComplexElement {
column: String,
cell_path: Vec<u8>,
value: Option<crate::types::Value>,
timestamp_micros: i64,
ttl_seconds: Option<u32>,
local_deletion_time: Option<i32>,
is_deleted: bool,
},
ComplexDeletion {
column: String,
marked_for_delete_at: i64,
local_deletion_time: i32,
},
}
impl From<PreCellLdtWriteTtlCellOperation> for CellOperation {
fn from(op: PreCellLdtWriteTtlCellOperation) -> Self {
match op {
PreCellLdtWriteTtlCellOperation::Write { column, value } => {
CellOperation::Write { column, value }
}
PreCellLdtWriteTtlCellOperation::WriteWithTtl {
column,
value,
ttl_seconds,
} => CellOperation::WriteWithTtl {
column,
value,
ttl_seconds,
local_deletion_time: None,
},
PreCellLdtWriteTtlCellOperation::Delete {
column,
local_deletion_time,
} => CellOperation::Delete {
column,
local_deletion_time,
},
PreCellLdtWriteTtlCellOperation::DeleteRow => CellOperation::DeleteRow,
PreCellLdtWriteTtlCellOperation::WriteComplexElement {
column,
cell_path,
value,
timestamp_micros,
ttl_seconds,
local_deletion_time,
is_deleted,
} => CellOperation::WriteComplexElement {
column,
cell_path,
value,
timestamp_micros,
ttl_seconds,
local_deletion_time,
is_deleted,
},
PreCellLdtWriteTtlCellOperation::ComplexDeletion {
column,
marked_for_delete_at,
local_deletion_time,
} => CellOperation::ComplexDeletion {
column,
marked_for_delete_at,
local_deletion_time,
},
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct PreCellLdtWriteTtlMutation {
table: TableId,
partition_key: PartitionKey,
clustering_key: Option<ClusteringKey>,
operations: Vec<PreCellLdtWriteTtlCellOperation>,
timestamp_micros: i64,
ttl_seconds: Option<u32>,
partition_tombstone: Option<PartitionTombstone>,
range_tombstones: Vec<RangeTombstone>,
local_deletion_time: Option<i32>,
row_tombstone: Option<(i64, i32)>,
cell_write_timestamps: Option<std::collections::HashMap<String, i64>>,
}
impl From<PreCellLdtWriteTtlMutation> for Mutation {
fn from(m: PreCellLdtWriteTtlMutation) -> Self {
Mutation {
table: m.table,
partition_key: m.partition_key,
clustering_key: m.clustering_key,
operations: m.operations.into_iter().map(CellOperation::from).collect(),
timestamp_micros: m.timestamp_micros,
ttl_seconds: m.ttl_seconds,
partition_tombstone: m.partition_tombstone,
range_tombstones: m.range_tombstones,
local_deletion_time: m.local_deletion_time,
row_tombstone: m.row_tombstone,
cell_write_timestamps: m.cell_write_timestamps,
}
}
}
fn decode_mutation(bytes: &[u8]) -> std::result::Result<Mutation, bincode::Error> {
match bincode::deserialize::<Mutation>(bytes) {
Ok(mutation) => Ok(mutation),
Err(current_err) => {
if let Ok(m) = bincode::deserialize::<PreCellLdtWriteTtlMutation>(bytes) {
return Ok(Mutation::from(m));
}
if let Ok(m) = bincode::deserialize::<PreCellWriteTimestampsMutation>(bytes) {
return Ok(Mutation::from(m));
}
if let Ok(m) = bincode::deserialize::<PreRowTombstoneMutation>(bytes) {
return Ok(Mutation::from(m));
}
if let Ok(m) = bincode::deserialize::<LegacyMutationWithLdt>(bytes) {
return Ok(Mutation::from(m));
}
bincode::deserialize::<LegacyMutation>(bytes)
.map(Mutation::from)
.map_err(|_| current_err)
}
}
}
pub struct WriteAheadLog {
file: BufWriter<File>,
path: PathBuf,
#[allow(dead_code)]
buffer_size: usize,
current_size: u64,
append_scratch: Vec<u8>,
pending_valid_prefix: Option<u64>,
poisoned: Option<String>,
#[cfg(test)]
fail_sync_after_truncate: bool,
#[cfg(test)]
fail_seek_after_truncate: bool,
}
impl std::fmt::Debug for WriteAheadLog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut dbg = f.debug_struct("WriteAheadLog");
dbg.field("file", &self.file)
.field("path", &self.path)
.field("buffer_size", &self.buffer_size)
.field("current_size", &self.current_size)
.field(
"append_scratch",
&format_args!(
"<{} bytes, cap {}>",
self.append_scratch.len(),
self.append_scratch.capacity()
),
)
.field("pending_valid_prefix", &self.pending_valid_prefix)
.field("poisoned", &self.poisoned);
#[cfg(test)]
dbg.field("fail_sync_after_truncate", &self.fail_sync_after_truncate)
.field("fail_seek_after_truncate", &self.fail_seek_after_truncate);
dbg.finish()
}
}
impl WriteAheadLog {
pub const DEFAULT_BUFFER_SIZE: usize = 4096;
pub const WAL_FILENAME: &'static str = "commitlog.wal";
pub fn create(dir: &Path) -> Result<Self> {
Self::create_with_buffer_size(dir, Self::DEFAULT_BUFFER_SIZE)
}
pub fn create_with_buffer_size(dir: &Path, buffer_size: usize) -> Result<Self> {
let validated_dir = validate_wal_directory(dir)?;
let path = validated_dir.join(Self::WAL_FILENAME);
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)
.map_err(|e| Error::Storage(format!("Failed to create WAL at {:?}: {}", path, e)))?;
set_secure_permissions(&file)?;
sync_directory(&validated_dir)?;
Ok(Self {
file: BufWriter::with_capacity(buffer_size, file),
path,
buffer_size,
current_size: 0,
append_scratch: Vec::new(),
pending_valid_prefix: None,
poisoned: None,
#[cfg(test)]
fail_sync_after_truncate: false,
#[cfg(test)]
fail_seek_after_truncate: false,
})
}
pub fn open_existing(path: &Path) -> Result<Self> {
let (valid_end, stop) = Self::scan_valid_prefix(path)?;
let file = OpenOptions::new()
.read(true)
.append(true)
.open(path)
.map_err(|e| Error::Storage(format!("Failed to open WAL at {:?}: {}", path, e)))?;
let metadata = file
.metadata()
.map_err(|e| Error::Storage(format!("Failed to read WAL metadata: {}", e)))?;
let file_len = metadata.len();
let mut pending_valid_prefix = None;
let current_size = if stop == WalStop::TornTail && valid_end < file_len {
file.set_len(valid_end).map_err(|e| {
Error::Storage(format!("Failed to trim torn WAL tail at {:?}: {}", path, e))
})?;
file.sync_all().map_err(|e| {
Error::Storage(format!(
"Failed to sync WAL after trim at {:?}: {}",
path, e
))
})?;
if let Some(parent) = path.parent() {
sync_directory(parent)?;
}
tracing::warn!(
"WAL {:?} had a torn tail: trimmed {} byte(s) ({} -> {})",
path,
file_len - valid_end,
file_len,
valid_end
);
valid_end
} else {
if stop == WalStop::Corruption && valid_end < file_len {
tracing::error!(
"WAL {:?} has corruption at offset {} ({} valid prefix byte(s) of {}); \
leaving the segment intact for evidence preservation before reset",
path,
valid_end,
valid_end,
file_len
);
pending_valid_prefix = Some(valid_end);
}
file_len
};
Ok(Self {
file: BufWriter::with_capacity(Self::DEFAULT_BUFFER_SIZE, file),
path: path.to_path_buf(),
buffer_size: Self::DEFAULT_BUFFER_SIZE,
current_size,
append_scratch: Vec::new(),
pending_valid_prefix,
poisoned: None,
#[cfg(test)]
fail_sync_after_truncate: false,
#[cfg(test)]
fail_seek_after_truncate: false,
})
}
pub fn has_pending_corrupt_tail(&self) -> bool {
self.pending_valid_prefix.is_some()
}
pub fn reset_to_valid_prefix(&mut self) -> Result<Option<u64>> {
let valid_end = match self.pending_valid_prefix.as_ref() {
Some(end) => *end,
None => return Ok(None),
};
self.file
.flush()
.map_err(|e| Error::Storage(format!("Failed to flush WAL before reset: {}", e)))?;
let file_len = self
.file
.get_ref()
.metadata()
.map_err(|e| Error::Storage(format!("Failed to read WAL metadata for reset: {}", e)))?
.len();
if valid_end >= file_len {
self.current_size = file_len;
self.pending_valid_prefix = None;
return Ok(None);
}
self.file.get_mut().set_len(valid_end).map_err(|e| {
Error::Storage(format!(
"Failed to reset WAL to valid prefix at {:?}: {}",
self.path, e
))
})?;
self.file.get_ref().sync_all().map_err(|e| {
Error::Storage(format!(
"Failed to sync WAL after reset at {:?}: {}",
self.path, e
))
})?;
if let Some(parent) = self.path.parent() {
sync_directory(parent)?;
}
tracing::warn!(
"WAL {:?} reset to last valid prefix after lossy recovery: {} -> {} \
({} corrupt byte(s) dropped from the LIVE log; evidence preserved aside)",
self.path,
file_len,
valid_end,
file_len - valid_end
);
self.current_size = valid_end;
self.pending_valid_prefix = None;
Ok(Some(valid_end))
}
fn scan_valid_prefix(path: &Path) -> Result<(u64, WalStop)> {
let mut file = File::open(path)
.map_err(|e| Error::Storage(format!("Failed to open WAL for scan: {}", e)))?;
let mut offset = 0u64;
loop {
let mut header = [0u8; 8];
match file.read_exact(&mut header) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
let stop = if Self::at_clean_eof(&mut file, offset)? {
WalStop::CleanEof
} else {
WalStop::TornTail
};
return Ok((offset, stop));
}
Err(e) => {
return Err(Error::Storage(format!(
"Failed to scan WAL header at offset {}: {}",
offset, e
)));
}
}
let entry_length = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
let expected_crc = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
if entry_length > MAX_ENTRY_LENGTH {
return Ok((offset, WalStop::Corruption));
}
let mut payload = vec![0u8; entry_length as usize];
match file.read_exact(&mut payload) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Ok((offset, WalStop::Corruption));
}
Err(e) => {
return Err(Error::Storage(format!(
"Failed to scan WAL payload at offset {}: {}",
offset, e
)));
}
}
let mut hasher = Hasher::new();
hasher.update(&payload);
if hasher.finalize() != expected_crc {
return Ok((offset, WalStop::Corruption));
}
offset += 8 + entry_length as u64;
}
}
fn at_clean_eof(file: &mut File, offset: u64) -> Result<bool> {
let len = file
.metadata()
.map_err(|e| Error::Storage(format!("Failed to read WAL metadata during scan: {}", e)))?
.len();
Ok(len == offset)
}
#[tracing::instrument(name = "wal.append", level = "debug", skip(self, mutation))]
pub fn append(&mut self, mutation: &Mutation) -> Result<()> {
self.ensure_not_poisoned()?;
if self.pending_valid_prefix.is_some() {
return Err(Error::Storage(
"WAL has an unreset corrupt tail; reset_to_valid_prefix required before appending"
.to_string(),
));
}
self.append_scratch.clear();
bincode::serialize_into(&mut self.append_scratch, mutation)
.map_err(|e| Error::Storage(format!("Failed to serialize mutation: {}", e)))?;
if self.append_scratch.len() as u64 > MAX_ENTRY_LENGTH as u64 {
let serialized_len = self.append_scratch.len();
self.append_scratch.clear();
self.append_scratch.shrink_to_fit();
return Err(Error::Storage(format!(
"WAL entry exceeds MAX_ENTRY_LENGTH (16 MiB): {} bytes",
serialized_len
)));
}
let entry_length = self.append_scratch.len() as u32;
let mut hasher = Hasher::new();
hasher.update(&self.append_scratch);
let crc32 = hasher.finalize();
self.file
.write_all(&entry_length.to_le_bytes())
.map_err(|e| Error::Storage(format!("Failed to write entry length: {}", e)))?;
self.file
.write_all(&crc32.to_le_bytes())
.map_err(|e| Error::Storage(format!("Failed to write CRC32: {}", e)))?;
self.file
.write_all(&self.append_scratch)
.map_err(|e| Error::Storage(format!("Failed to write mutation bytes: {}", e)))?;
self.current_size += 8 + entry_length as u64;
Ok(())
}
#[tracing::instrument(name = "wal.sync", level = "debug", skip(self))]
pub fn sync(&mut self) -> Result<()> {
self.ensure_not_poisoned()?;
self.file
.flush()
.map_err(|e| Error::Storage(format!("Failed to flush WAL buffer: {}", e)))?;
let fsync_start = std::time::Instant::now();
self.file
.get_ref()
.sync_all()
.map_err(|e| Error::Storage(format!("Failed to sync WAL to disk: {}", e)))?;
crate::observability::record_histogram(
crate::observability::catalog::WAL_SYNC_DURATION,
fsync_start.elapsed().as_secs_f64(),
&[],
);
Ok(())
}
pub fn replay(&self) -> Result<RecoveryReport> {
let mut out = Vec::new();
let mut report = self.replay_each(|m| {
out.push(m);
Ok(())
})?;
report.mutations = out;
Ok(report)
}
pub fn replay_each<F>(&self, mut f: F) -> Result<RecoveryReport>
where
F: FnMut(Mutation) -> Result<()>,
{
let mut file = File::open(&self.path)
.map_err(|e| Error::Storage(format!("Failed to open WAL for replay: {}", e)))?;
let total_len = file
.metadata()
.map_err(|e| Error::Storage(format!("Failed to read WAL metadata for replay: {}", e)))?
.len();
let mut report = RecoveryReport::default();
let mut offset = 0u64;
let mut mutation_bytes: Vec<u8> = Vec::new();
loop {
let mut header = [0u8; 8];
match file.read_exact(&mut header) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
break;
}
Err(e) => {
return Err(Error::Storage(format!(
"Failed to read WAL header at offset {}: {}",
offset, e
)));
}
}
let entry_length = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
let expected_crc = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
if entry_length > MAX_ENTRY_LENGTH {
tracing::error!(
"WAL entry at offset {} declares implausible length {} (> {}) - stopping \
replay; {} trailing byte(s) not recovered",
offset,
entry_length,
MAX_ENTRY_LENGTH,
total_len.saturating_sub(offset)
);
report.corrupt_entries += 1;
report.stopped_early = true;
break;
}
mutation_bytes.resize(entry_length as usize, 0);
match file.read_exact(&mut mutation_bytes) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
tracing::error!(
"WAL entry at offset {} has a complete header declaring length {} but \
only {} payload byte(s) remain to EOF - stopping replay; {} trailing \
byte(s) not recovered (corruption, not a clean torn tail)",
offset,
entry_length,
total_len.saturating_sub(offset + 8),
total_len.saturating_sub(offset)
);
report.corrupt_entries += 1;
report.stopped_early = true;
break;
}
Err(e) => {
return Err(Error::Storage(format!(
"Failed to read WAL entry at offset {}: {}",
offset, e
)));
}
}
let mut hasher = Hasher::new();
hasher.update(&mutation_bytes);
let actual_crc = hasher.finalize();
if actual_crc != expected_crc {
tracing::error!(
"WAL entry at offset {} has CRC mismatch (expected 0x{:08x}, got 0x{:08x}) - \
stopping replay; {} trailing byte(s) not recovered",
offset,
expected_crc,
actual_crc,
total_len.saturating_sub(offset)
);
report.corrupt_entries += 1;
report.stopped_early = true;
break;
}
match decode_mutation(&mutation_bytes) {
Ok(mutation) => {
f(mutation)?;
}
Err(e) => {
tracing::error!(
"WAL entry at offset {} passed CRC but failed to deserialize: {} - \
skipping this entry and continuing",
offset,
e
);
report.corrupt_entries += 1;
}
}
offset += 8 + entry_length as u64;
}
report.bytes_skipped = total_len.saturating_sub(offset);
Ok(report)
}
pub fn truncate(&mut self) -> Result<()> {
self.truncate_checked().map_err(TruncateError::into_inner)
}
pub fn truncate_checked(&mut self) -> std::result::Result<(), TruncateError> {
if let Some(reason) = &self.poisoned {
return Err(TruncateError::AfterMutation(Error::Storage(format!(
"WAL is poisoned and cannot be truncated: {reason}"
))));
}
self.file.flush().map_err(|e| {
TruncateError::BeforeMutation(Error::Storage(format!(
"Failed to flush before truncate: {}",
e
)))
})?;
self.file.get_mut().set_len(0).map_err(|e| {
TruncateError::BeforeMutation(Error::Storage(format!("Failed to truncate WAL: {}", e)))
})?;
let sync_res = self.sync_after_truncate();
let seek_res = self.seek_after_truncate();
self.current_size = 0;
if let Err(e) = seek_res {
let reason = format!("seek after truncate failed: {e}");
self.poisoned = Some(reason);
return Err(TruncateError::AfterMutation(Error::Storage(format!(
"Failed to seek after truncate; WAL poisoned to prevent a sparse \
append: {e}"
))));
}
if let Err(e) = sync_res {
return Err(TruncateError::AfterMutation(Error::Storage(format!(
"Failed to sync after truncate: {}",
e
))));
}
self.pending_valid_prefix = None;
Ok(())
}
fn ensure_not_poisoned(&self) -> Result<()> {
if let Some(reason) = &self.poisoned {
return Err(Error::Storage(format!(
"WAL is poisoned after a failed truncate-restore and must be \
reopened before further writes: {reason}"
)));
}
Ok(())
}
fn sync_after_truncate(&self) -> std::io::Result<()> {
#[cfg(test)]
if self.fail_sync_after_truncate {
return Err(std::io::Error::other("injected post-set_len(0) sync fault"));
}
self.file.get_ref().sync_all()
}
fn seek_after_truncate(&mut self) -> std::io::Result<u64> {
#[cfg(test)]
if self.fail_seek_after_truncate {
return Err(std::io::Error::other("injected post-set_len(0) seek fault"));
}
self.file.get_mut().seek(SeekFrom::Start(0))
}
#[cfg(test)]
pub(crate) fn set_fail_sync_after_truncate(&mut self, fail: bool) {
self.fail_sync_after_truncate = fail;
}
#[cfg(test)]
pub(crate) fn set_fail_seek_after_truncate(&mut self, fail: bool) {
self.fail_seek_after_truncate = fail;
}
pub fn size(&self) -> u64 {
self.current_size
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn rotate(mut self, dir: &Path) -> Result<Self> {
self.sync()?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let old_path = self.path.clone();
let archived_path = dir.join(format!("commitlog.wal.{}", timestamp));
drop(self.file);
std::fs::rename(&old_path, &archived_path)
.map_err(|e| Error::Storage(format!("Failed to rename WAL during rotation: {}", e)))?;
sync_directory(dir)?;
Self::create(dir)
}
pub fn delete_old(path: &Path) -> Result<()> {
std::fs::remove_file(path)
.map_err(|e| Error::Storage(format!("Failed to delete old WAL: {}", e)))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::write_engine::mutation::{
CellOperation, ClusteringKey, Mutation, PartitionKey, TableId,
};
use crate::types::Value;
use tempfile::TempDir;
fn create_test_mutation(id: i32, name: &str) -> Mutation {
let table_id = TableId::new("test_ks", "test_table");
let pk = PartitionKey::single("id", Value::Integer(id));
let ops = vec![CellOperation::Write {
column: "name".to_string(),
value: Value::Text(name.to_string()),
}];
Mutation::new(table_id, pk, None, ops, 1234567890, None)
}
#[test]
fn test_wal_create() {
let temp_dir = TempDir::new().unwrap();
let wal = WriteAheadLog::create(temp_dir.path()).unwrap();
assert_eq!(wal.size(), 0);
assert!(wal.path().exists());
}
#[test]
fn test_wal_append_and_sync() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutation = create_test_mutation(1, "Alice");
wal.append(&mutation).unwrap();
assert!(wal.size() > 0);
wal.sync().unwrap();
}
#[test]
fn test_wal_replay_empty() {
let temp_dir = TempDir::new().unwrap();
let wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 0);
}
#[test]
fn test_wal_replay_single_entry() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutation = create_test_mutation(1, "Alice");
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 1);
assert_eq!(mutations[0].table.keyspace, "test_ks");
assert_eq!(mutations[0].table.table, "test_table");
}
#[test]
fn test_wal_replay_multiple_entries() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
for i in 0..10 {
let mutation = create_test_mutation(i, &format!("User{}", i));
wal.append(&mutation).unwrap();
}
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 10);
for (i, mutation) in mutations.iter().enumerate() {
assert_eq!(mutation.table.keyspace, "test_ks");
match &mutation.operations[0] {
CellOperation::Write { column, value } => {
assert_eq!(column, "name");
if let Value::Text(name) = value {
assert_eq!(name, &format!("User{}", i));
} else {
panic!("Expected Text value");
}
}
_ => panic!("Expected Write operation"),
}
}
}
#[test]
fn test_wal_truncate() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutation = create_test_mutation(1, "Alice");
wal.append(&mutation).unwrap();
wal.sync().unwrap();
assert!(wal.size() > 0);
wal.truncate().unwrap();
assert_eq!(wal.size(), 0);
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 0);
}
#[test]
fn truncate_post_setlen_sync_failure_resets_state_for_replay() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
for i in 0..3 {
wal.append(&create_test_mutation(i, &format!("Old{}", i)))
.unwrap();
}
wal.sync().unwrap();
assert!(wal.size() > 0);
wal.set_fail_sync_after_truncate(true);
let err = wal
.truncate_checked()
.expect_err("post-set_len(0) sync fault must surface");
assert!(
matches!(err, TruncateError::AfterMutation(_)),
"a failure after set_len(0) must be AfterMutation (WAL already zeroed)"
);
assert_eq!(wal.size(), 0, "current_size must be reset to 0");
wal.set_fail_sync_after_truncate(false);
wal.append(&create_test_mutation(42, "New")).unwrap();
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(
mutations.len(),
1,
"replay must return exactly the post-fault write, with no sparse gap"
);
assert_eq!(mutations[0].table.keyspace, "test_ks");
assert_eq!(
mutations[0].partition_key.columns[0].1,
Value::Integer(42),
"the replayed entry must be the new write, correctly parsed"
);
}
#[test]
fn truncate_post_setlen_seek_failure_poisons_wal_no_sparse_append() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
for i in 0..3 {
wal.append(&create_test_mutation(i, &format!("Old{i}")))
.unwrap();
}
wal.sync().unwrap();
assert!(wal.size() > 0);
wal.set_fail_seek_after_truncate(true);
let err = wal
.truncate_checked()
.expect_err("post-set_len(0) seek fault must surface");
assert!(
matches!(err, TruncateError::AfterMutation(_)),
"a failure after set_len(0) must be AfterMutation (WAL already zeroed)"
);
wal.set_fail_seek_after_truncate(false);
let append_err = wal
.append(&create_test_mutation(42, "New"))
.expect_err("append on a poisoned WAL must fail closed");
assert!(
matches!(append_err, Error::Storage(_)),
"poisoned-WAL append must return a typed storage error, got {append_err:?}"
);
assert!(
wal.sync().is_err(),
"sync on a poisoned WAL must fail closed"
);
assert!(
matches!(wal.truncate_checked(), Err(TruncateError::AfterMutation(_))),
"truncate on a poisoned WAL must fail closed as AfterMutation"
);
let wal_path = wal.path().to_path_buf();
drop(wal);
let reopened = WriteAheadLog::open_existing(&wal_path).unwrap();
assert_eq!(
reopened.replay().unwrap().mutations.len(),
0,
"no sparse / lost / misparsed entry may exist after a poisoned truncate"
);
}
#[test]
fn test_wal_crc_corruption() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutation = create_test_mutation(1, "Alice");
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let wal_path = wal.path().to_path_buf();
drop(wal);
let mut file = OpenOptions::new().write(true).open(&wal_path).unwrap();
file.seek(SeekFrom::Start(4)).unwrap();
file.write_all(&[0xFF, 0xFF, 0xFF, 0xFF]).unwrap();
file.sync_all().unwrap();
drop(file);
let wal = WriteAheadLog::open_existing(&wal_path).unwrap();
let report = wal.replay().unwrap();
assert_eq!(report.mutations.len(), 0);
assert!(
!report.is_clean(),
"CRC corruption must surface as non-clean"
);
assert_eq!(report.corrupt_entries, 1);
assert!(report.stopped_early);
}
#[test]
fn test_wal_truncated_entry() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutation = create_test_mutation(1, "Alice");
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let wal_path = wal.path().to_path_buf();
let original_size = wal.size();
drop(wal);
let file = OpenOptions::new().write(true).open(&wal_path).unwrap();
file.set_len(original_size - 10).unwrap();
drop(file);
let wal = WriteAheadLog::open_existing(&wal_path).unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 0);
}
#[test]
fn test_wal_rotate() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutation = create_test_mutation(1, "Alice");
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let wal = wal.rotate(temp_dir.path()).unwrap();
assert_eq!(wal.size(), 0);
let archived_files: Vec<_> = std::fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("commitlog.wal.")
})
.collect();
assert_eq!(archived_files.len(), 1);
}
#[test]
fn test_wal_delete_old() {
let temp_dir = TempDir::new().unwrap();
let wal_path = temp_dir.path().join("test.wal");
File::create(&wal_path).unwrap();
assert!(wal_path.exists());
WriteAheadLog::delete_old(&wal_path).unwrap();
assert!(!wal_path.exists());
}
#[test]
fn test_wal_open_existing() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutation1 = create_test_mutation(1, "Alice");
wal.append(&mutation1).unwrap();
wal.sync().unwrap();
let wal_path = wal.path().to_path_buf();
drop(wal);
let mut wal = WriteAheadLog::open_existing(&wal_path).unwrap();
let mutation2 = create_test_mutation(2, "Bob");
wal.append(&mutation2).unwrap();
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 2);
}
#[test]
fn test_wal_with_clustering_key() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let table_id = TableId::new("test_ks", "test_table");
let pk = PartitionKey::single("id", Value::Integer(1));
let ck = Some(ClusteringKey::single("ts", Value::Timestamp(1000)));
let ops = vec![CellOperation::Write {
column: "value".to_string(),
value: Value::Text("test".to_string()),
}];
let mutation = Mutation::new(table_id, pk, ck, ops, 1234567890, None);
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 1);
assert!(mutations[0].clustering_key.is_some());
}
#[test]
fn test_wal_with_ttl() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let table_id = TableId::new("test_ks", "test_table");
let pk = PartitionKey::single("id", Value::Integer(1));
let ops = vec![CellOperation::Write {
column: "value".to_string(),
value: Value::Text("test".to_string()),
}];
let mutation = Mutation::new(table_id, pk, None, ops, 1234567890, Some(3600));
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 1);
assert_eq!(mutations[0].ttl_seconds, Some(3600));
}
#[test]
fn test_wal_delete_operation() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let table_id = TableId::new("test_ks", "test_table");
let pk = PartitionKey::single("id", Value::Integer(1));
let ops = vec![CellOperation::Delete {
column: "name".to_string(),
local_deletion_time: None,
}];
let mutation = Mutation::new(table_id, pk, None, ops, 1234567890, None);
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 1);
assert!(matches!(
&mutations[0].operations[0],
CellOperation::Delete { .. }
));
}
#[test]
fn test_wal_delete_row_operation() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let table_id = TableId::new("test_ks", "test_table");
let pk = PartitionKey::single("id", Value::Integer(1));
let ops = vec![CellOperation::DeleteRow];
let mutation = Mutation::new(table_id, pk, None, ops, 1234567890, None);
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 1);
assert!(matches!(
&mutations[0].operations[0],
CellOperation::DeleteRow
));
}
#[test]
fn test_wal_roundtrips_explicit_local_deletion_time() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let table_id = TableId::new("test_ks", "test_table");
let pk = PartitionKey::single("id", Value::Integer(7));
let ops = vec![CellOperation::DeleteRow];
let mutation = Mutation::new(table_id, pk, None, ops, 1_700_000_000_000_000, None)
.with_local_deletion_time(1_650_000_000);
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 1);
assert_eq!(
mutations[0].local_deletion_time,
Some(1_650_000_000),
"Explicit local_deletion_time must round-trip through the WAL"
);
}
#[test]
fn test_wal_default_local_deletion_time_is_none() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutation = create_test_mutation(1, "Alice");
assert_eq!(mutation.local_deletion_time, None);
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 1);
assert_eq!(mutations[0].local_deletion_time, None);
}
#[test]
fn test_wal_decodes_legacy_record_without_mutation_local_deletion_time() {
let legacy = LegacyMutation {
table: TableId::new("ks", "tbl"),
partition_key: PartitionKey::single("id", Value::Integer(3)),
clustering_key: None,
operations: vec![LegacyCellOperation::Delete {
column: "name".to_string(),
}],
timestamp_micros: 1_234_567_890,
ttl_seconds: None,
partition_tombstone: None,
range_tombstones: Vec::new(),
};
let legacy_bytes = bincode::serialize(&legacy).unwrap();
let decoded = decode_mutation(&legacy_bytes).expect("legacy record must decode");
assert_eq!(decoded.local_deletion_time, None);
assert_eq!(decoded.timestamp_micros, 1_234_567_890);
assert!(matches!(
&decoded.operations[0],
CellOperation::Delete {
local_deletion_time: None,
..
}
));
}
#[test]
fn test_wal_decodes_legacy_delete_op_without_local_deletion_time() {
let legacy = LegacyMutation {
table: TableId::new("ks", "tbl"),
partition_key: PartitionKey::single("id", Value::Integer(7)),
clustering_key: None,
operations: vec![
LegacyCellOperation::Delete {
column: "dropped_col".to_string(),
},
LegacyCellOperation::Write {
column: "name".to_string(),
value: Value::Text("Bob".to_string()),
},
],
timestamp_micros: 999_000,
ttl_seconds: None,
partition_tombstone: None,
range_tombstones: Vec::new(),
};
let legacy_bytes = bincode::serialize(&legacy).unwrap();
let current = Mutation::new(
TableId::new("ks", "tbl"),
PartitionKey::single("id", Value::Integer(7)),
None,
vec![
CellOperation::Delete {
column: "dropped_col".to_string(),
local_deletion_time: None,
},
CellOperation::Write {
column: "name".to_string(),
value: Value::Text("Bob".to_string()),
},
],
999_000,
None,
);
let current_bytes = bincode::serialize(¤t).unwrap();
assert!(
current_bytes.len() > legacy_bytes.len(),
"Current Delete layout is strictly longer (adds the Option<i32> \
discriminant for local_deletion_time): current={}, legacy={}",
current_bytes.len(),
legacy_bytes.len()
);
let decoded = decode_mutation(&legacy_bytes).expect("legacy #921 record must decode");
assert_eq!(decoded.timestamp_micros, 999_000);
assert_eq!(decoded.operations.len(), 2);
match &decoded.operations[0] {
CellOperation::Delete {
column,
local_deletion_time,
} => {
assert_eq!(column, "dropped_col");
assert_eq!(*local_deletion_time, None);
}
other => panic!("expected Delete, got {other:?}"),
}
match &decoded.operations[1] {
CellOperation::Write { column, value } => {
assert_eq!(column, "name");
assert_eq!(value, &Value::Text("Bob".to_string()));
}
other => panic!("expected Write, got {other:?}"),
}
}
#[test]
fn test_wal_roundtrips_delete_with_explicit_local_deletion_time() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutation = Mutation::new(
TableId::new("ks", "tbl"),
PartitionKey::single("id", Value::Integer(11)),
None,
vec![CellOperation::Delete {
column: "name".to_string(),
local_deletion_time: Some(1_700_000_000),
}],
1_700_000_000_000_000,
None,
);
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 1);
match &mutations[0].operations[0] {
CellOperation::Delete {
column,
local_deletion_time,
} => {
assert_eq!(column, "name");
assert_eq!(*local_deletion_time, Some(1_700_000_000));
}
other => panic!("expected Delete, got {other:?}"),
}
}
#[test]
fn test_wal_decodes_layout_b_record_preserving_mutation_local_deletion_time() {
let legacy = LegacyMutationWithLdt {
table: TableId::new("ks", "tbl"),
partition_key: PartitionKey::single("id", Value::Integer(13)),
clustering_key: None,
operations: vec![
LegacyCellOperation::Delete {
column: "dropped_col".to_string(),
},
LegacyCellOperation::Write {
column: "name".to_string(),
value: Value::Text("Carol".to_string()),
},
],
timestamp_micros: 1_650_000_000_000_000,
ttl_seconds: None,
partition_tombstone: None,
range_tombstones: Vec::new(),
local_deletion_time: Some(1_650_000_000),
};
let legacy_bytes = bincode::serialize(&legacy).unwrap();
let current_attempt = bincode::deserialize::<Mutation>(&legacy_bytes);
let current_recovers_faithfully = matches!(
¤t_attempt,
Ok(m) if m.operations.len() == 2
&& matches!(&m.operations[0], CellOperation::Delete { column, .. } if column == "dropped_col")
&& matches!(&m.operations[1], CellOperation::Write { column, value }
if column == "name" && value == &Value::Text("Carol".to_string()))
);
assert!(
!current_recovers_faithfully,
"current layout must NOT faithfully decode a layout (B) record; \
otherwise the (B) compat path is never exercised"
);
let decoded = decode_mutation(&legacy_bytes).expect("layout (B) record must decode");
assert_eq!(decoded.local_deletion_time, Some(1_650_000_000));
assert_eq!(decoded.timestamp_micros, 1_650_000_000_000_000);
assert_eq!(decoded.operations.len(), 2);
match &decoded.operations[0] {
CellOperation::Delete {
column,
local_deletion_time,
} => {
assert_eq!(column, "dropped_col");
assert_eq!(*local_deletion_time, None);
}
other => panic!("expected Delete, got {other:?}"),
}
match &decoded.operations[1] {
CellOperation::Write { column, value } => {
assert_eq!(column, "name");
assert_eq!(value, &Value::Text("Carol".to_string()));
}
other => panic!("expected Write, got {other:?}"),
}
}
#[test]
fn test_wal_decodes_layout_e_pre_1538_write_with_ttl_preserving_trailing_fields() {
let pre1538 = PreCellLdtWriteTtlMutation {
table: TableId::new("ks", "tbl"),
partition_key: PartitionKey::single("id", Value::Integer(17)),
clustering_key: None,
operations: vec![
PreCellLdtWriteTtlCellOperation::WriteWithTtl {
column: "session".to_string(),
value: Value::Text("abc".to_string()),
ttl_seconds: 3600,
},
PreCellLdtWriteTtlCellOperation::Delete {
column: "dropped_col".to_string(),
local_deletion_time: Some(1_710_000_000),
},
],
timestamp_micros: 1_710_000_000_000_000,
ttl_seconds: None,
partition_tombstone: None,
range_tombstones: Vec::new(),
local_deletion_time: Some(1_710_000_001),
row_tombstone: Some((1_710_000_002_000_000, 1_710_000_002)),
cell_write_timestamps: Some(
[("session".to_string(), 1_710_000_003_000_000)]
.into_iter()
.collect(),
),
};
let pre1538_bytes = bincode::serialize(&pre1538).unwrap();
let current_attempt = bincode::deserialize::<Mutation>(&pre1538_bytes);
let current_recovers_faithfully = matches!(
¤t_attempt,
Ok(m) if m.operations.len() == 2
&& matches!(&m.operations[0],
CellOperation::WriteWithTtl { column, ttl_seconds, .. }
if column == "session" && *ttl_seconds == 3600)
&& matches!(&m.operations[1],
CellOperation::Delete { column, local_deletion_time }
if column == "dropped_col" && *local_deletion_time == Some(1_710_000_000))
&& m.local_deletion_time == Some(1_710_000_001)
&& m.row_tombstone == Some((1_710_000_002_000_000, 1_710_000_002))
);
assert!(
!current_recovers_faithfully,
"current layout must NOT faithfully decode a layout (E) record; \
otherwise the (E) compat path is never exercised / a pre-#1538 \
WriteWithTtl record would decode-but-wrong"
);
let decoded = decode_mutation(&pre1538_bytes).expect("layout (E) record must decode");
assert_eq!(decoded.timestamp_micros, 1_710_000_000_000_000);
assert_eq!(decoded.local_deletion_time, Some(1_710_000_001));
assert_eq!(
decoded.row_tombstone,
Some((1_710_000_002_000_000, 1_710_000_002))
);
assert_eq!(
decoded
.cell_write_timestamps
.as_ref()
.and_then(|m| m.get("session").copied()),
Some(1_710_000_003_000_000)
);
assert_eq!(decoded.operations.len(), 2);
match &decoded.operations[0] {
CellOperation::WriteWithTtl {
column,
value,
ttl_seconds,
local_deletion_time,
} => {
assert_eq!(column, "session");
assert_eq!(value, &Value::Text("abc".to_string()));
assert_eq!(*ttl_seconds, 3600);
assert_eq!(*local_deletion_time, None);
}
other => panic!("expected WriteWithTtl, got {other:?}"),
}
match &decoded.operations[1] {
CellOperation::Delete {
column,
local_deletion_time,
} => {
assert_eq!(column, "dropped_col");
assert_eq!(*local_deletion_time, Some(1_710_000_000));
}
other => panic!("expected Delete, got {other:?}"),
}
}
#[test]
fn test_wal_decodes_layout_c_pre_1538_write_with_ttl_no_row_tombstone() {
let era_c = PreRowTombstoneMutation {
table: TableId::new("ks", "tbl"),
partition_key: PartitionKey::single("id", Value::Integer(21)),
clustering_key: None,
operations: vec![
PreCellLdtWriteTtlCellOperation::WriteWithTtl {
column: "session".to_string(),
value: Value::Text("cee".to_string()),
ttl_seconds: 900,
},
PreCellLdtWriteTtlCellOperation::Delete {
column: "dropped_col".to_string(),
local_deletion_time: Some(1_650_000_000),
},
],
timestamp_micros: 1_650_000_000_000_000,
ttl_seconds: None,
partition_tombstone: None,
range_tombstones: Vec::new(),
local_deletion_time: Some(1_650_000_001),
};
let era_c_bytes = bincode::serialize(&era_c).unwrap();
let current_attempt = bincode::deserialize::<Mutation>(&era_c_bytes);
let current_recovers_faithfully = matches!(
¤t_attempt,
Ok(m) if m.operations.len() == 2
&& matches!(&m.operations[0],
CellOperation::WriteWithTtl { column, ttl_seconds, .. }
if column == "session" && *ttl_seconds == 900)
&& matches!(&m.operations[1],
CellOperation::Delete { column, local_deletion_time }
if column == "dropped_col" && *local_deletion_time == Some(1_650_000_000))
);
assert!(
!current_recovers_faithfully,
"current layout must NOT faithfully decode a layout (C) record; \
otherwise a pre-#1538 WriteWithTtl would decode-but-wrong (silent \
corruption gap)"
);
let decoded = decode_mutation(&era_c_bytes).expect("layout (C) record must decode");
assert_eq!(decoded.timestamp_micros, 1_650_000_000_000_000);
assert_eq!(decoded.local_deletion_time, Some(1_650_000_001));
assert_eq!(decoded.row_tombstone, None);
assert_eq!(decoded.cell_write_timestamps, None);
assert_eq!(decoded.operations.len(), 2);
match &decoded.operations[0] {
CellOperation::WriteWithTtl {
column,
value,
ttl_seconds,
local_deletion_time,
} => {
assert_eq!(column, "session");
assert_eq!(value, &Value::Text("cee".to_string()));
assert_eq!(*ttl_seconds, 900);
assert_eq!(*local_deletion_time, None);
}
other => panic!("expected WriteWithTtl, got {other:?}"),
}
match &decoded.operations[1] {
CellOperation::Delete {
column,
local_deletion_time,
} => {
assert_eq!(column, "dropped_col");
assert_eq!(*local_deletion_time, Some(1_650_000_000));
}
other => panic!("expected Delete, got {other:?}"),
}
}
#[test]
fn test_wal_decodes_layout_d_pre_1538_write_with_ttl_with_row_tombstone() {
let era_d = PreCellWriteTimestampsMutation {
table: TableId::new("ks", "tbl"),
partition_key: PartitionKey::single("id", Value::Integer(23)),
clustering_key: None,
operations: vec![
PreCellLdtWriteTtlCellOperation::WriteWithTtl {
column: "session".to_string(),
value: Value::Text("dee".to_string()),
ttl_seconds: 1800,
},
PreCellLdtWriteTtlCellOperation::Delete {
column: "dropped_col".to_string(),
local_deletion_time: Some(1_680_000_000),
},
],
timestamp_micros: 1_680_000_000_000_000,
ttl_seconds: None,
partition_tombstone: None,
range_tombstones: Vec::new(),
local_deletion_time: Some(1_680_000_001),
row_tombstone: Some((1_680_000_002_000_000, 1_680_000_002)),
};
let era_d_bytes = bincode::serialize(&era_d).unwrap();
let current_attempt = bincode::deserialize::<Mutation>(&era_d_bytes);
let current_recovers_faithfully = matches!(
¤t_attempt,
Ok(m) if m.operations.len() == 2
&& matches!(&m.operations[0],
CellOperation::WriteWithTtl { column, ttl_seconds, .. }
if column == "session" && *ttl_seconds == 1800)
&& matches!(&m.operations[1],
CellOperation::Delete { column, local_deletion_time }
if column == "dropped_col" && *local_deletion_time == Some(1_680_000_000))
&& m.row_tombstone == Some((1_680_000_002_000_000, 1_680_000_002))
);
assert!(
!current_recovers_faithfully,
"current layout must NOT faithfully decode a layout (D) record; \
otherwise a pre-#1538 WriteWithTtl would decode-but-wrong (silent \
corruption gap)"
);
let decoded = decode_mutation(&era_d_bytes).expect("layout (D) record must decode");
assert_eq!(decoded.timestamp_micros, 1_680_000_000_000_000);
assert_eq!(decoded.local_deletion_time, Some(1_680_000_001));
assert_eq!(
decoded.row_tombstone,
Some((1_680_000_002_000_000, 1_680_000_002))
);
assert_eq!(decoded.cell_write_timestamps, None);
assert_eq!(decoded.operations.len(), 2);
match &decoded.operations[0] {
CellOperation::WriteWithTtl {
column,
value,
ttl_seconds,
local_deletion_time,
} => {
assert_eq!(column, "session");
assert_eq!(value, &Value::Text("dee".to_string()));
assert_eq!(*ttl_seconds, 1800);
assert_eq!(*local_deletion_time, None);
}
other => panic!("expected WriteWithTtl, got {other:?}"),
}
match &decoded.operations[1] {
CellOperation::Delete {
column,
local_deletion_time,
} => {
assert_eq!(column, "dropped_col");
assert_eq!(*local_deletion_time, Some(1_680_000_000));
}
other => panic!("expected Delete, got {other:?}"),
}
}
#[test]
fn test_wal_current_write_with_ttl_ldt_decodes_via_current_layout() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mut mutation = Mutation::new(
TableId::new("ks", "tbl"),
PartitionKey::single("id", Value::Integer(19)),
None,
vec![CellOperation::WriteWithTtl {
column: "session".to_string(),
value: Value::Text("xyz".to_string()),
ttl_seconds: 7200,
local_deletion_time: Some(1_720_007_200),
}],
1_720_000_000_000_000,
None,
);
mutation.local_deletion_time = Some(1_720_000_000);
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 1);
assert_eq!(mutations[0].local_deletion_time, Some(1_720_000_000));
match &mutations[0].operations[0] {
CellOperation::WriteWithTtl {
column,
ttl_seconds,
local_deletion_time,
..
} => {
assert_eq!(column, "session");
assert_eq!(*ttl_seconds, 7200);
assert_eq!(*local_deletion_time, Some(1_720_007_200));
}
other => panic!("expected WriteWithTtl, got {other:?}"),
}
}
#[test]
fn test_wal_buffer_size() {
let temp_dir = TempDir::new().unwrap();
let wal = WriteAheadLog::create_with_buffer_size(temp_dir.path(), 8192).unwrap();
assert_eq!(wal.buffer_size, 8192);
}
#[test]
fn test_wal_directory_sync_on_create() {
let temp_dir = TempDir::new().unwrap();
let wal = WriteAheadLog::create(temp_dir.path()).unwrap();
assert!(wal.path().exists());
}
#[test]
fn test_wal_directory_sync_on_rotate() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutation = create_test_mutation(1, "Alice");
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let new_wal = wal.rotate(temp_dir.path()).unwrap();
assert!(new_wal.path().exists());
let archived_files: Vec<_> = std::fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("commitlog.wal.")
})
.collect();
assert_eq!(archived_files.len(), 1);
}
#[test]
fn test_wal_fsync_after_truncate() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let mutation = create_test_mutation(1, "Alice");
wal.append(&mutation).unwrap();
wal.sync().unwrap();
let size_before = wal.size();
assert!(size_before > 0);
wal.truncate().unwrap();
assert_eq!(wal.size(), 0);
let metadata = std::fs::metadata(wal.path()).unwrap();
assert_eq!(metadata.len(), 0);
}
#[test]
fn test_validate_wal_directory_nonexistent() {
let nonexistent = PathBuf::from("/nonexistent/path/that/does/not/exist");
let result = validate_wal_directory(&nonexistent);
assert!(result.is_err());
match result {
Err(Error::InvalidPath(_)) => {}
_ => panic!("Expected InvalidPath error"),
}
}
#[test]
fn test_validate_wal_directory_is_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("not_a_dir");
File::create(&file_path).unwrap();
let result = validate_wal_directory(&file_path);
assert!(result.is_err());
match result {
Err(Error::InvalidPath(_)) => {}
_ => panic!("Expected InvalidPath error"),
}
}
#[test]
fn test_validate_wal_directory_valid() {
let temp_dir = TempDir::new().unwrap();
let result = validate_wal_directory(temp_dir.path());
assert!(result.is_ok());
let canonical = result.unwrap();
assert!(canonical.is_absolute());
}
#[test]
#[cfg(unix)]
fn test_wal_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let metadata = std::fs::metadata(wal.path()).unwrap();
let permissions = metadata.permissions();
let mode = permissions.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn test_wal_create_validates_directory() {
let temp_dir = TempDir::new().unwrap();
let result = WriteAheadLog::create(temp_dir.path());
assert!(result.is_ok());
let nonexistent = temp_dir.path().join("nonexistent");
let result = WriteAheadLog::create(&nonexistent);
assert!(result.is_err());
}
fn write_two_entries(temp_dir: &TempDir) -> (PathBuf, u64, u64) {
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
wal.append(&create_test_mutation(1, "Alice")).unwrap();
wal.sync().unwrap();
let end_of_a = wal.size();
wal.append(&create_test_mutation(2, "Bob")).unwrap();
wal.sync().unwrap();
let total = wal.size();
let path = wal.path().to_path_buf();
drop(wal);
(path, end_of_a, total)
}
#[test]
fn test_wal_open_existing_preserves_complete_header_short_payload() {
let temp_dir = TempDir::new().unwrap();
let (path, end_of_a, total) = write_two_entries(&temp_dir);
let torn_len = end_of_a + 10;
assert!(
torn_len < total,
"test setup: B must be longer than 2 payload bytes"
);
let file = OpenOptions::new().write(true).open(&path).unwrap();
file.set_len(torn_len).unwrap();
file.sync_all().unwrap();
drop(file);
let wal = WriteAheadLog::open_existing(&path).unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
torn_len,
"a complete-header short-payload entry must be preserved on disk, not trimmed"
);
assert!(
wal.has_pending_corrupt_tail(),
"a complete-header short-payload entry must record a pending valid prefix"
);
let report = wal.replay().unwrap();
assert_eq!(report.mutations.len(), 1, "only A is recovered");
assert!(
!report.is_clean(),
"the dropped complete-header entry is lossy"
);
assert!(report.stopped_early);
}
#[test]
fn test_wal_post_reopen_write_is_recoverable() {
let temp_dir = TempDir::new().unwrap();
let (path, end_of_a, total) = write_two_entries(&temp_dir);
let torn_len = end_of_a + 10;
assert!(torn_len < total);
let file = OpenOptions::new().write(true).open(&path).unwrap();
file.set_len(torn_len).unwrap();
file.sync_all().unwrap();
drop(file);
{
let mut wal = WriteAheadLog::open_existing(&path).unwrap();
let reset_to = wal.reset_to_valid_prefix().unwrap();
assert_eq!(reset_to, Some(end_of_a), "reset trims back to the end of A");
wal.append(&create_test_mutation(3, "Carol")).unwrap();
wal.sync().unwrap();
}
let wal = WriteAheadLog::open_existing(&path).unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(
mutations.len(),
2,
"must recover A and C (C must be present)"
);
let names: Vec<&str> = mutations
.iter()
.map(|m| match &m.operations[0] {
CellOperation::Write {
value: Value::Text(name),
..
} => name.as_str(),
other => panic!("expected Write op, got {other:?}"),
})
.collect();
assert_eq!(names, vec!["Alice", "Carol"]);
}
#[test]
fn test_wal_open_existing_trims_torn_header() {
let temp_dir = TempDir::new().unwrap();
let (path, end_of_a, _total) = write_two_entries(&temp_dir);
let torn_len = end_of_a + 3;
let file = OpenOptions::new().write(true).open(&path).unwrap();
file.set_len(torn_len).unwrap();
file.sync_all().unwrap();
drop(file);
{
let mut wal = WriteAheadLog::open_existing(&path).unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
end_of_a,
"torn header must be trimmed to the last CRC-valid boundary"
);
wal.append(&create_test_mutation(3, "Carol")).unwrap();
wal.sync().unwrap();
}
let wal = WriteAheadLog::open_existing(&path).unwrap();
let mutations = wal.replay().unwrap().mutations;
assert_eq!(
mutations.len(),
2,
"must recover A and C after torn-header trim"
);
}
#[test]
fn test_wal_open_existing_clean_log_unaffected() {
let temp_dir = TempDir::new().unwrap();
let (path, _end_of_a, total) = write_two_entries(&temp_dir);
{
let wal = WriteAheadLog::open_existing(&path).unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
total,
"a clean log must not be truncated"
);
assert_eq!(wal.size(), total);
let mutations = wal.replay().unwrap().mutations;
assert_eq!(mutations.len(), 2, "clean log preserves all entries");
}
{
let mut wal = WriteAheadLog::open_existing(&path).unwrap();
wal.append(&create_test_mutation(3, "Carol")).unwrap();
wal.sync().unwrap();
}
let wal = WriteAheadLog::open_existing(&path).unwrap();
assert_eq!(wal.replay().unwrap().mutations.len(), 3);
}
#[test]
fn test_wal_open_existing_preserves_entries_after_midstream_crc_corruption() {
let temp_dir = TempDir::new().unwrap();
let (path, end_of_a, total) = {
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
wal.append(&create_test_mutation(1, "Alice")).unwrap();
wal.sync().unwrap();
let end_of_a = wal.size();
wal.append(&create_test_mutation(2, "Bob")).unwrap();
wal.sync().unwrap();
wal.append(&create_test_mutation(3, "Carol")).unwrap();
wal.sync().unwrap();
let total = wal.size();
let path = wal.path().to_path_buf();
drop(wal);
(path, end_of_a, total)
};
let mut file = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
file.seek(SeekFrom::Start(end_of_a + 4)).unwrap();
let mut crc = [0u8; 4];
file.read_exact(&mut crc).unwrap();
crc[0] ^= 0xFF;
file.seek(SeekFrom::Start(end_of_a + 4)).unwrap();
file.write_all(&crc).unwrap();
file.sync_all().unwrap();
drop(file);
let wal = WriteAheadLog::open_existing(&path).unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
total,
"a mid-stream CRC-corrupt (but structurally complete) entry must NOT be trimmed"
);
assert_eq!(wal.size(), total);
assert!(
wal.has_pending_corrupt_tail(),
"mid-stream corruption must be recorded as a pending corrupt tail, not trimmed away"
);
let report = wal.replay().unwrap();
assert!(
!report.is_clean(),
"mid-stream CRC corruption must be reported, not silently swallowed"
);
assert!(
report.stopped_early,
"replay must stop at the untrusted mid-stream corruption"
);
assert_eq!(report.corrupt_entries, 1, "B must be reported corrupt");
let names: Vec<&str> = report
.mutations
.iter()
.map(|m| match &m.operations[0] {
CellOperation::Write {
value: Value::Text(name),
..
} => name.as_str(),
other => panic!("expected Write op, got {other:?}"),
})
.collect();
assert_eq!(
names,
vec!["Alice"],
"replay recovers the valid prefix [A] and reports the rest as lossy (never silent)"
);
}
#[test]
fn test_wal_open_existing_trims_torn_first_header() {
for garbage_len in 1..8usize {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join(WriteAheadLog::WAL_FILENAME);
std::fs::write(&path, vec![0xABu8; garbage_len]).unwrap();
{
let mut wal = WriteAheadLog::open_existing(&path).unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
0,
"torn first header ({garbage_len} byte(s)) must be trimmed to 0, not kept"
);
assert_eq!(wal.size(), 0);
assert!(
!wal.has_pending_corrupt_tail(),
"a torn tail is trimmed in open, not left pending"
);
wal.append(&create_test_mutation(3, "Carol")).unwrap();
wal.sync().unwrap();
}
let wal = WriteAheadLog::open_existing(&path).unwrap();
let report = wal.replay().unwrap();
assert!(
report.is_clean(),
"recovery must be clean after trimming the torn first header \
(garbage_len={garbage_len})"
);
let names: Vec<&str> = report
.mutations
.iter()
.map(|m| match &m.operations[0] {
CellOperation::Write {
value: Value::Text(name),
..
} => name.as_str(),
other => panic!("expected Write op, got {other:?}"),
})
.collect();
assert_eq!(
names,
vec!["Carol"],
"replay must yield exactly [C] (garbage_len={garbage_len})"
);
}
}
#[test]
fn test_wal_append_rejected_until_reset_after_midstream_corruption() {
let temp_dir = TempDir::new().unwrap();
let (path, end_of_a, total) = write_two_entries(&temp_dir);
let b_first_payload = end_of_a + 8;
assert!(
b_first_payload < total,
"test setup: B must have at least one payload byte"
);
{
let mut bytes = std::fs::read(&path).unwrap();
bytes[b_first_payload as usize] ^= 0xFF;
std::fs::write(&path, &bytes).unwrap();
}
let mut wal = WriteAheadLog::open_existing(&path).unwrap();
assert!(
wal.has_pending_corrupt_tail(),
"mid-stream corruption must leave a pending valid prefix, not silently trim"
);
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
total,
"the corrupt segment must remain on disk for evidence preservation"
);
let err = wal
.append(&create_test_mutation(3, "Carol"))
.expect_err("append must be rejected while a corrupt tail is unreset");
match err {
Error::Storage(msg) => assert!(
msg.contains("reset_to_valid_prefix"),
"error must direct the caller to reset first, got: {msg}"
),
other => panic!("expected Error::Storage, got {other:?}"),
}
let reset_to = wal.reset_to_valid_prefix().unwrap();
assert_eq!(reset_to, Some(end_of_a), "reset trims back to the end of A");
assert!(
!wal.has_pending_corrupt_tail(),
"reset_to_valid_prefix must clear the pending flag"
);
wal.append(&create_test_mutation(3, "Carol")).unwrap();
wal.sync().unwrap();
drop(wal);
let wal = WriteAheadLog::open_existing(&path).unwrap();
let report = wal.replay().unwrap();
assert!(
report.is_clean(),
"recovery must be clean after reset + post-recovery append"
);
let names: Vec<&str> = report
.mutations
.iter()
.map(|m| match &m.operations[0] {
CellOperation::Write {
value: Value::Text(name),
..
} => name.as_str(),
other => panic!("expected Write op, got {other:?}"),
})
.collect();
assert_eq!(
names,
vec!["Alice", "Carol"],
"replay must yield exactly [A, C]"
);
}
#[test]
fn test_wal_truncate_clears_pending_corrupt_tail_guard() {
let temp_dir = TempDir::new().unwrap();
let (path, end_of_a, total) = write_two_entries(&temp_dir);
let b_first_payload = end_of_a + 8;
assert!(
b_first_payload < total,
"test setup: B must have at least one payload byte"
);
{
let mut bytes = std::fs::read(&path).unwrap();
bytes[b_first_payload as usize] ^= 0xFF;
std::fs::write(&path, &bytes).unwrap();
}
let mut wal = WriteAheadLog::open_existing(&path).unwrap();
assert!(
wal.has_pending_corrupt_tail(),
"mid-stream corruption must leave a pending valid prefix"
);
wal.truncate().unwrap();
assert!(
!wal.has_pending_corrupt_tail(),
"truncate() must clear the pending corrupt-tail guard on an emptied WAL"
);
assert_eq!(wal.size(), 0, "truncate() must reset current_size to zero");
wal.append(&create_test_mutation(3, "Carol")).unwrap();
wal.sync().unwrap();
drop(wal);
let wal = WriteAheadLog::open_existing(&path).unwrap();
let report = wal.replay().unwrap();
assert!(
report.is_clean(),
"recovery must be clean after truncate + post-truncate append"
);
let names: Vec<&str> = report
.mutations
.iter()
.map(|m| match &m.operations[0] {
CellOperation::Write {
value: Value::Text(name),
..
} => name.as_str(),
other => panic!("expected Write op, got {other:?}"),
})
.collect();
assert_eq!(names, vec!["Carol"], "replay must yield exactly [C]");
}
#[test]
fn test_wal_append_rejects_oversize_entry_matching_replay_limit() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
let path = wal.path().to_path_buf();
let over = "x".repeat(MAX_ENTRY_LENGTH as usize + 1);
let err = wal
.append(&create_test_mutation(1, &over))
.expect_err("over-limit append must be rejected, not acknowledged");
match err {
Error::Storage(msg) => assert!(
msg.contains("MAX_ENTRY_LENGTH"),
"error must cite the size ceiling, got: {msg}"
),
other => panic!("expected Error::Storage, got {other:?}"),
}
assert_eq!(
wal.size(),
0,
"a rejected append must not write or grow the WAL"
);
let under = "y".repeat(MAX_ENTRY_LENGTH as usize - 8192);
wal.append(&create_test_mutation(2, &under)).unwrap();
wal.sync().unwrap();
drop(wal);
let wal = WriteAheadLog::open_existing(&path).unwrap();
let report = wal.replay().unwrap();
assert!(
report.is_clean(),
"just-under-limit entry must replay cleanly"
);
assert_eq!(report.mutations.len(), 1, "exactly the accepted entry");
match &report.mutations[0].operations[0] {
CellOperation::Write {
value: Value::Text(name),
..
} => assert_eq!(name.len(), under.len(), "recovered payload must match"),
other => panic!("expected Write op, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn test_wal_reset_failure_keeps_guard_set() {
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("wal_sub");
std::fs::create_dir(&subdir).unwrap();
let (path, end_of_a, total) = {
let mut wal = WriteAheadLog::create(&subdir).unwrap();
wal.append(&create_test_mutation(1, "Alice")).unwrap();
wal.sync().unwrap();
let end_of_a = wal.size();
wal.append(&create_test_mutation(2, "Bob")).unwrap();
wal.sync().unwrap();
let total = wal.size();
let path = wal.path().to_path_buf();
(path, end_of_a, total)
};
let b_first_payload = end_of_a + 8;
assert!(
b_first_payload < total,
"test setup: B must have a payload byte"
);
{
let mut bytes = std::fs::read(&path).unwrap();
bytes[b_first_payload as usize] ^= 0xFF;
std::fs::write(&path, &bytes).unwrap();
}
let mut wal = WriteAheadLog::open_existing(&path).unwrap();
assert!(
wal.has_pending_corrupt_tail(),
"mid-stream corruption must record a pending valid prefix"
);
std::fs::remove_dir_all(&subdir).unwrap();
let err = wal
.reset_to_valid_prefix()
.expect_err("reset must propagate the directory-sync failure");
assert!(matches!(err, Error::Storage(_)), "expected Error::Storage");
assert!(
wal.has_pending_corrupt_tail(),
"the guard MUST remain set when the reset sequence fails partway"
);
let append_err = wal
.append(&create_test_mutation(3, "Carol"))
.expect_err("append must remain rejected after a failed reset");
match append_err {
Error::Storage(msg) => assert!(
msg.contains("reset_to_valid_prefix"),
"error must still direct the caller to reset first, got: {msg}"
),
other => panic!("expected Error::Storage, got {other:?}"),
}
}
fn write_three_entries(temp_dir: &TempDir) -> (PathBuf, u64, u64, u64) {
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
wal.append(&create_test_mutation(1, "Alice")).unwrap();
wal.sync().unwrap();
let end_of_a = wal.size();
wal.append(&create_test_mutation(2, "Bob")).unwrap();
wal.sync().unwrap();
let end_of_b = wal.size();
wal.append(&create_test_mutation(3, "Carol")).unwrap();
wal.sync().unwrap();
let total = wal.size();
let path = wal.path().to_path_buf();
drop(wal);
(path, end_of_a, end_of_b, total)
}
#[test]
fn test_wal_midstream_length_past_eof_is_lossy_not_clean() {
let temp_dir = TempDir::new().unwrap();
let (path, end_of_a, _end_of_b, total) = write_three_entries(&temp_dir);
let remaining_after_b_header = total - (end_of_a + 8);
let bogus_len = remaining_after_b_header + 4096;
assert!(
bogus_len <= MAX_ENTRY_LENGTH as u64,
"test setup: bogus length must stay within MAX_ENTRY_LENGTH so it \
exercises the past-EOF path, not the oversize path"
);
assert!(
bogus_len > remaining_after_b_header,
"test setup: bogus length must overshoot EOF"
);
{
let mut bytes = std::fs::read(&path).unwrap();
let len_field = (end_of_a as usize)..(end_of_a as usize + 4);
bytes[len_field].copy_from_slice(&(bogus_len as u32).to_le_bytes());
std::fs::write(&path, &bytes).unwrap();
}
let wal = WriteAheadLog::open_existing(&path).unwrap();
assert!(
wal.has_pending_corrupt_tail(),
"a complete-header entry with an unsatisfiable length must be preserved \
pending (not silently trimmed as a torn tail)"
);
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
total,
"the B/C tail must remain on disk for evidence preservation"
);
let report = wal.replay().unwrap();
assert!(
!report.is_clean(),
"a length-past-EOF middle entry must NOT report a clean recovery"
);
assert!(
report.stopped_early,
"replay must stop at the corrupt frame"
);
assert_eq!(report.corrupt_entries, 1, "the mis-sized entry is corrupt");
assert!(
report.bytes_skipped > 0,
"the discarded B/C tail must be counted as skipped, not lost silently"
);
assert_eq!(
report.mutations.len(),
1,
"only the valid prefix A survives"
);
match &report.mutations[0].operations[0] {
CellOperation::Write {
value: Value::Text(name),
..
} => assert_eq!(name, "Alice", "the sole recovered entry must be A"),
other => panic!("expected Write op, got {other:?}"),
}
}
#[test]
fn test_replay_each_matches_replay_for_mixed_wal() {
let temp_dir = TempDir::new().unwrap();
let (path, end_of_a) = {
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
wal.append(&create_test_mutation(1, "Alice")).unwrap();
wal.sync().unwrap();
let end_of_a = wal.size();
wal.append(&create_test_mutation(2, "Bob")).unwrap();
wal.sync().unwrap();
wal.append(&create_test_mutation(3, "Carol")).unwrap();
wal.sync().unwrap();
let path = wal.path().to_path_buf();
drop(wal);
(path, end_of_a)
};
let mut file = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
file.seek(SeekFrom::Start(end_of_a + 4)).unwrap();
let mut crc = [0u8; 4];
file.read_exact(&mut crc).unwrap();
crc[0] ^= 0xFF;
file.seek(SeekFrom::Start(end_of_a + 4)).unwrap();
file.write_all(&crc).unwrap();
file.sync_all().unwrap();
drop(file);
let wal = WriteAheadLog::open_existing(&path).unwrap();
let report = wal.replay().unwrap();
let mut streamed = Vec::new();
let stream_report = wal
.replay_each(|m| {
streamed.push(m);
Ok(())
})
.unwrap();
let ser = |m: &Mutation| bincode::serialize(m).unwrap();
let expected: Vec<Vec<u8>> = report.mutations.iter().map(ser).collect();
let actual: Vec<Vec<u8>> = streamed.iter().map(ser).collect();
assert_eq!(
actual, expected,
"replay_each must yield the same mutations in the same order as replay()"
);
assert_eq!(streamed.len(), 1, "only the valid prefix A is recovered");
assert_eq!(stream_report.corrupt_entries, report.corrupt_entries);
assert_eq!(stream_report.stopped_early, report.stopped_early);
assert_eq!(stream_report.bytes_skipped, report.bytes_skipped);
assert!(
stream_report.stopped_early,
"must stop at the corrupt entry"
);
assert_eq!(stream_report.corrupt_entries, 1);
assert!(
stream_report.mutations.is_empty(),
"replay_each streams mutations to the callback, so its report Vec stays empty"
);
}
#[test]
fn test_replay_each_clean_multi_entry() {
let temp_dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(temp_dir.path()).unwrap();
wal.append(&create_test_mutation(1, "Alice")).unwrap();
wal.append(&create_test_mutation(2, "Bob")).unwrap();
wal.append(&create_test_mutation(3, "Carol")).unwrap();
wal.sync().unwrap();
let report = wal.replay().unwrap();
let mut streamed = Vec::new();
let stream_report = wal
.replay_each(|m| {
streamed.push(m);
Ok(())
})
.unwrap();
let ser = |m: &Mutation| bincode::serialize(m).unwrap();
let expected: Vec<Vec<u8>> = report.mutations.iter().map(ser).collect();
let actual: Vec<Vec<u8>> = streamed.iter().map(ser).collect();
assert_eq!(actual, expected);
assert_eq!(streamed.len(), 3);
assert!(stream_report.is_clean());
assert!(report.is_clean());
}
#[test]
fn test_sync_directory_invalid_path() {
let invalid_path = PathBuf::from("/nonexistent/path");
let result = sync_directory(&invalid_path);
assert!(result.is_err());
match result {
Err(Error::Storage(_)) => {}
_ => panic!("Expected Storage error"),
}
}
}