use crate::error::{Error, Result};
use crate::storage::write_engine::wal::{TruncateError, WriteAheadLog};
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub(crate) enum FlushDurabilityOutcome {
Durable,
WalTruncateFailedAfterCommit(Error),
}
pub(crate) trait DurabilityBarrier {
fn sync_dir(&self, dir: &Path) -> Result<()>;
fn truncate_wal(&self, wal: &mut WriteAheadLog) -> std::result::Result<(), TruncateError>;
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct RealDurabilityBarrier;
impl DurabilityBarrier for RealDurabilityBarrier {
fn sync_dir(&self, dir: &Path) -> Result<()> {
sync_directory(dir)
}
fn truncate_wal(&self, wal: &mut WriteAheadLog) -> std::result::Result<(), TruncateError> {
wal.truncate_checked()
}
}
#[cfg(unix)]
pub(crate) fn sync_directory(dir: &Path) -> Result<()> {
let handle = std::fs::File::open(dir).map_err(|e| {
Error::Storage(format!(
"Failed to open data directory {} for fsync: {e}",
dir.display()
))
})?;
handle.sync_all().map_err(|e| {
Error::Storage(format!(
"Failed to fsync data directory {}: {e}",
dir.display()
))
})
}
#[cfg(not(unix))]
pub(crate) fn sync_directory(_dir: &Path) -> Result<()> {
Ok(())
}
pub(crate) fn create_dir_all(path: &Path) -> Result<()> {
if path.as_os_str().is_empty() {
return Ok(());
}
match std::fs::create_dir(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
create_dir_all(parent)?;
}
_ => {
return Err(Error::Storage(format!(
"Failed to create directory {}: {e}",
path.display()
)));
}
}
match std::fs::create_dir(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() => Ok(()),
Err(e) => Err(Error::Storage(format!(
"Failed to create directory {}: {e}",
path.display()
))),
}
}
Err(e) => Err(Error::Storage(format!(
"Failed to create directory {}: {e}",
path.display()
))),
}
}
pub(crate) fn dirs_to_sync(leaf: &Path, data_root: &Path) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
let mut cur = leaf;
loop {
dirs.push(cur.to_path_buf());
if cur == data_root {
break;
}
match cur.parent() {
Some(parent) if !parent.as_os_str().is_empty() && parent.starts_with(data_root) => {
cur = parent;
}
_ => break,
}
}
dirs
}
pub(crate) fn finalize_flush_durability(
barrier: &dyn DurabilityBarrier,
data_path: &Path,
data_dir: &Path,
wal: &mut WriteAheadLog,
) -> Result<FlushDurabilityOutcome> {
let sstable_dir = data_path.parent().unwrap_or(data_dir);
for dir in dirs_to_sync(sstable_dir, data_dir) {
barrier.sync_dir(&dir)?;
}
if wal.size() == 0 {
return Ok(FlushDurabilityOutcome::Durable);
}
match barrier.truncate_wal(wal) {
Ok(()) => Ok(FlushDurabilityOutcome::Durable),
Err(TruncateError::BeforeMutation(e)) => {
tracing::warn!(
"WAL truncate failed before mutating the WAL ({e}). Leaving the \
WAL intact as a durable replay marker; the next startup will \
replay it idempotently (last-write-wins), so no data is lost."
);
Ok(FlushDurabilityOutcome::Durable)
}
Err(TruncateError::AfterMutation(e)) => {
Ok(FlushDurabilityOutcome::WalTruncateFailedAfterCommit(
Error::Storage(format!(
"WAL truncate failed AFTER zeroing the WAL ({e}); the WAL is \
no longer a replayable recovery marker. The flushed SSTable \
is durable and the generation has been advanced so a retry \
will not overwrite it."
)),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::write_engine::mutation::{CellOperation, Mutation, PartitionKey, TableId};
use crate::types::Value;
use std::cell::RefCell;
use tempfile::TempDir;
#[derive(Debug, Clone, PartialEq, Eq)]
enum FsOp {
SyncDir(PathBuf),
WalTruncate,
}
fn 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)
}
struct RecordingBarrier {
ops: RefCell<Vec<FsOp>>,
fail_dir_sync: bool,
fail_truncate_before_mutation: bool,
fail_truncate_after_mutation: bool,
}
impl RecordingBarrier {
fn new() -> Self {
Self {
ops: RefCell::new(Vec::new()),
fail_dir_sync: false,
fail_truncate_before_mutation: false,
fail_truncate_after_mutation: false,
}
}
}
impl DurabilityBarrier for RecordingBarrier {
fn sync_dir(&self, dir: &Path) -> Result<()> {
if self.fail_dir_sync {
return Err(Error::Storage("injected dir fsync fault".to_string()));
}
self.ops.borrow_mut().push(FsOp::SyncDir(dir.to_path_buf()));
Ok(())
}
fn truncate_wal(&self, wal: &mut WriteAheadLog) -> std::result::Result<(), TruncateError> {
if self.fail_truncate_before_mutation {
return Err(TruncateError::BeforeMutation(Error::Storage(
"injected pre-mutation truncate fault".to_string(),
)));
}
if self.fail_truncate_after_mutation {
wal.truncate().map_err(TruncateError::AfterMutation)?;
return Err(TruncateError::AfterMutation(Error::Storage(
"injected post-set_len truncate fault".to_string(),
)));
}
self.ops.borrow_mut().push(FsOp::WalTruncate);
wal.truncate().map_err(TruncateError::BeforeMutation)
}
}
#[test]
fn dir_fsync_precedes_wal_truncate() {
let dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(dir.path()).unwrap();
wal.append(&test_mutation(1, "Alice")).unwrap();
wal.sync().unwrap();
let barrier = RecordingBarrier::new();
let outcome = finalize_flush_durability(
&barrier,
&dir.path().join("nb-1-big-Data.db"),
dir.path(),
&mut wal,
)
.unwrap();
assert!(matches!(outcome, FlushDurabilityOutcome::Durable));
assert_eq!(
barrier.ops.into_inner(),
vec![FsOp::SyncDir(dir.path().to_path_buf()), FsOp::WalTruncate],
"directory fsync must be recorded before the WAL truncate"
);
assert_eq!(wal.replay().unwrap().mutations.len(), 0);
}
#[test]
fn faulted_dir_fsync_leaves_wal_intact() {
let dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(dir.path()).unwrap();
wal.append(&test_mutation(7, "Bob")).unwrap();
wal.sync().unwrap();
let mut barrier = RecordingBarrier::new();
barrier.fail_dir_sync = true;
let err = finalize_flush_durability(
&barrier,
&dir.path().join("nb-1-big-Data.db"),
dir.path(),
&mut wal,
)
.unwrap_err();
assert!(
matches!(err, Error::Storage(_)),
"faulted dir fsync must surface as an error"
);
assert!(barrier.ops.into_inner().is_empty());
let replayed = wal.replay().unwrap().mutations;
assert_eq!(
replayed.len(),
1,
"WAL must remain untruncated on dir-fsync fault"
);
}
#[test]
fn faulted_truncate_leaves_replayable_wal() {
let dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(dir.path()).unwrap();
wal.append(&test_mutation(9, "Carol")).unwrap();
wal.sync().unwrap();
let mut barrier = RecordingBarrier::new();
barrier.fail_truncate_before_mutation = true;
let outcome = finalize_flush_durability(
&barrier,
&dir.path().join("nb-1-big-Data.db"),
dir.path(),
&mut wal,
)
.expect("truncate fault must not fail the flush");
assert!(
matches!(outcome, FlushDurabilityOutcome::Durable),
"a before-mutation truncate fault leaves a replayable WAL: still Durable"
);
assert_eq!(
barrier.ops.into_inner(),
vec![FsOp::SyncDir(dir.path().to_path_buf())]
);
let replayed = wal.replay().unwrap().mutations;
assert_eq!(
replayed.len(),
1,
"failed truncate must leave the WAL intact for replay, not lose data"
);
assert_eq!(replayed[0].table.keyspace, "test_ks");
}
#[test]
fn faulted_truncate_after_mutation_reports_committed_with_error() {
let dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(dir.path()).unwrap();
wal.append(&test_mutation(11, "Dave")).unwrap();
wal.sync().unwrap();
let mut barrier = RecordingBarrier::new();
barrier.fail_truncate_after_mutation = true;
let outcome = finalize_flush_durability(
&barrier,
&dir.path().join("nb-1-big-Data.db"),
dir.path(),
&mut wal,
)
.expect("a post-mutation truncate failure is COMMITTED, not a hard error");
let err = match outcome {
FlushDurabilityOutcome::WalTruncateFailedAfterCommit(e) => e,
FlushDurabilityOutcome::Durable => {
panic!("post-mutation truncate failure must not report Durable")
}
};
assert!(
matches!(err, Error::Storage(msg) if msg.contains("no longer a replayable recovery marker")),
"post-mutation truncate failure must carry a storage error"
);
assert_eq!(
barrier.ops.into_inner(),
vec![FsOp::SyncDir(dir.path().to_path_buf())]
);
let replayed = wal.replay().unwrap().mutations;
assert_eq!(
replayed.len(),
0,
"the WAL was zeroed by set_len(0); it is no longer replayable, which \
is why finalize must NOT report success"
);
}
#[test]
fn empty_wal_skips_truncate_phase() {
let dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(dir.path()).unwrap();
assert_eq!(wal.size(), 0, "a fresh, unwritten WAL must have size 0");
let mut barrier = RecordingBarrier::new();
barrier.fail_truncate_after_mutation = true;
let outcome = finalize_flush_durability(
&barrier,
&dir.path().join("nb-1-big-Data.db"),
dir.path(),
&mut wal,
)
.expect("an empty-WAL flush must not enter the truncate phase");
assert!(
matches!(outcome, FlushDurabilityOutcome::Durable),
"empty WAL: the truncate phase is skipped, so the flush stays Durable"
);
assert_eq!(
barrier.ops.into_inner(),
vec![FsOp::SyncDir(dir.path().to_path_buf())],
"dir fsync still runs; the truncate phase is skipped for an empty WAL"
);
}
#[test]
fn real_barrier_syncs_existing_directory() {
let dir = TempDir::new().unwrap();
let mut wal = WriteAheadLog::create(dir.path()).unwrap();
wal.append(&test_mutation(1, "Alice")).unwrap();
wal.sync().unwrap();
finalize_flush_durability(
&RealDurabilityBarrier,
&dir.path().join("nb-1-big-Data.db"),
dir.path(),
&mut wal,
)
.unwrap();
assert_eq!(wal.replay().unwrap().mutations.len(), 0);
}
#[test]
fn first_flush_fsyncs_new_ancestor_dirs_before_truncate() {
let root = TempDir::new().unwrap();
let data_dir = root.path();
let mut wal = WriteAheadLog::create(data_dir).unwrap();
wal.append(&test_mutation(1, "Alice")).unwrap();
wal.sync().unwrap();
let ks_dir = data_dir.join("test_ks");
let leaf_dir = ks_dir.join("test_table");
let data_path = leaf_dir.join("nb-1-big-Data.db");
let barrier = RecordingBarrier::new();
finalize_flush_durability(&barrier, &data_path, data_dir, &mut wal).unwrap();
assert_eq!(
barrier.ops.into_inner(),
vec![
FsOp::SyncDir(leaf_dir),
FsOp::SyncDir(ks_dir),
FsOp::SyncDir(data_dir.to_path_buf()),
FsOp::WalTruncate,
],
"every ancestor dir up to the data root must be fsynced (deepest \
first) before the WAL truncate"
);
assert_eq!(wal.replay().unwrap().mutations.len(), 0);
}
#[test]
fn retry_with_preexisting_dirs_still_fsyncs_full_chain_before_truncate() {
let root = TempDir::new().unwrap();
let data_dir = root.path();
let mut wal = WriteAheadLog::create(data_dir).unwrap();
wal.append(&test_mutation(1, "Alice")).unwrap();
wal.sync().unwrap();
let ks_dir = data_dir.join("test_ks");
let leaf_dir = ks_dir.join("test_table");
create_dir_all(&leaf_dir).unwrap();
assert!(leaf_dir.exists());
let data_path = leaf_dir.join("nb-1-big-Data.db");
let barrier = RecordingBarrier::new();
finalize_flush_durability(&barrier, &data_path, data_dir, &mut wal).unwrap();
assert_eq!(
barrier.ops.into_inner(),
vec![
FsOp::SyncDir(leaf_dir),
FsOp::SyncDir(ks_dir),
FsOp::SyncDir(data_dir.to_path_buf()),
FsOp::WalTruncate,
],
"a retry over pre-existing dirs must still fsync the full leaf→\
data-root chain (not just the leaf) before the WAL truncate"
);
assert_eq!(wal.replay().unwrap().mutations.len(), 0);
}
#[test]
fn create_dir_all_is_idempotent() {
let root = TempDir::new().unwrap();
let ks = root.path().join("ks");
let tbl = ks.join("tbl");
create_dir_all(&tbl).unwrap();
assert!(tbl.is_dir());
create_dir_all(&tbl).unwrap();
let tbl2 = ks.join("tbl2");
create_dir_all(&tbl2).unwrap();
assert!(tbl2.is_dir());
}
#[test]
fn dirs_to_sync_is_full_chain_deepest_first() {
let root = TempDir::new().unwrap();
let data_dir = root.path().to_path_buf();
let ks = data_dir.join("ks");
let leaf = ks.join("tbl");
let dirs = dirs_to_sync(&leaf, &data_dir);
assert_eq!(dirs, vec![leaf, ks, data_dir.clone()]);
assert_eq!(dirs_to_sync(&data_dir, &data_dir), vec![data_dir.clone()]);
let stray = TempDir::new().unwrap();
let stray_leaf = stray.path().join("elsewhere");
assert_eq!(
dirs_to_sync(&stray_leaf, &data_dir),
vec![stray_leaf],
"must not fsync directories outside the configured data root"
);
}
}