use super::{WriteEngine, WriteEngineConfig};
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct SweepOutcome {
pub(crate) removed: Vec<PathBuf>,
pub(crate) failures: Vec<String>,
}
impl SweepOutcome {
pub(crate) fn had_failures(&self) -> bool {
!self.failures.is_empty()
}
}
impl WriteEngine {
pub(crate) fn sweep_startup_orphans(config: &WriteEngineConfig) {
let data_dir = &config.data_dir;
let tmp = Self::sweep_orphaned_compaction_tmp(data_dir);
let partial = Self::sweep_orphaned_partial_sstables(
data_dir,
&config.schema.keyspace,
&config.schema.table,
);
for outcome in [&tmp, &partial] {
if outcome.had_failures() {
for failure in &outcome.failures {
log::warn!(
"startup orphan sweep left an un-removable orphan (non-fatal, \
will retry next startup): {}",
failure
);
}
}
}
}
pub(crate) fn sweep_orphaned_compaction_tmp(data_dir: &Path) -> SweepOutcome {
let mut outcome = SweepOutcome::default();
let read_dir = match std::fs::read_dir(data_dir) {
Ok(rd) => rd,
Err(e) => {
log::debug!(
"sweep_orphaned_compaction_tmp: cannot read {:?}: {}",
data_dir,
e
);
return outcome;
}
};
for entry in read_dir.flatten() {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(".compaction-tmp-") && path.is_dir() {
log::warn!("removing orphaned compaction tmp directory: {:?}", path);
match std::fs::remove_dir_all(&path) {
Ok(()) => outcome.removed.push(path),
Err(e) => {
log::warn!(
"failed to remove orphaned compaction tmp directory {:?}: {}",
path,
e
);
outcome.failures.push(format!("{:?}: {}", path, e));
}
}
}
}
outcome
}
pub(crate) fn sweep_orphaned_partial_sstables(
data_dir: &Path,
keyspace: &str,
table: &str,
) -> SweepOutcome {
let mut outcome = SweepOutcome::default();
let sstable_dir = data_dir.join(keyspace).join(table);
let read_dir = match std::fs::read_dir(&sstable_dir) {
Ok(rd) => rd,
Err(_) => {
return outcome;
}
};
for entry in read_dir.flatten() {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("nb-")
|| !name_str.ends_with("-big-Data.db")
|| !path.is_file()
{
continue;
}
let base = match name_str.strip_suffix("-Data.db") {
Some(b) => b.to_owned(),
None => continue,
};
let gen_str = base
.strip_prefix("nb-")
.and_then(|s| s.strip_suffix("-big"))
.unwrap_or(&base);
let toc_path = sstable_dir.join(format!("{}-TOC.txt", base));
if !toc_path.exists() {
log::warn!(
"removing orphaned partial SSTable components for generation {}: missing TOC.txt",
gen_str
);
match Self::delete_sstable_files_static(&path) {
Ok(()) => outcome.removed.push(path.clone()),
Err(e) => {
log::warn!(
"failed to remove orphaned partial SSTable for generation {}: {}",
gen_str,
e
);
outcome
.failures
.push(format!("generation {}: {}", gen_str, e));
}
}
}
}
outcome
}
}
#[cfg(all(test, feature = "write-support"))]
mod tests {
use crate::schema::TableSchema;
use crate::storage::sstable::reader::compaction_row::CompactionRowData;
use crate::storage::sstable::reader::SSTableReader;
use crate::storage::write_engine::test_support::{create_test_schema, flush_n_sstables_sync};
use crate::storage::write_engine::{WriteEngine, WriteEngineConfig};
use std::collections::BTreeSet;
use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
fn config_for(temp_dir: &TempDir) -> WriteEngineConfig {
WriteEngineConfig::new(
temp_dir.path().join("data"),
temp_dir.path().join("wal"),
create_test_schema(),
)
}
fn sstable_dir(temp_dir: &TempDir) -> std::path::PathBuf {
temp_dir
.path()
.join("data")
.join("test_ks")
.join("test_table")
}
fn dir_names(dir: &Path) -> BTreeSet<String> {
std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default()
}
fn live_row_count(data_path: &Path, schema: &TableSchema) -> usize {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let config = crate::Config::default();
let platform =
std::sync::Arc::new(crate::platform::Platform::new(&config).await.unwrap());
let reader = SSTableReader::open(data_path, &config, platform)
.await
.unwrap();
let rows = reader
.iterate_all_partitions_for_compaction(Some(schema))
.await
.unwrap();
rows.iter()
.filter(|r| matches!(r.row_data, CompactionRowData::Live { .. }))
.count()
})
}
#[cfg(unix)]
fn readonly_dir_blocks_delete() -> bool {
use std::os::unix::fs::PermissionsExt;
let probe = TempDir::new().unwrap();
let victim = probe.path().join("victim");
std::fs::write(&victim, b"x").unwrap();
std::fs::set_permissions(probe.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
let blocked = std::fs::remove_file(&victim).is_err();
std::fs::set_permissions(probe.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
blocked
}
#[test]
fn true_orphan_removed_live_generation_untouched_and_readable() {
let temp_dir = TempDir::new().unwrap();
let schema = create_test_schema();
let mut engine = WriteEngine::new(config_for(&temp_dir)).unwrap();
let live_paths = flush_n_sstables_sync(&mut engine, 1);
let live_data = live_paths[0].clone();
drop(engine);
let dir = sstable_dir(&temp_dir);
let live_bytes_before = std::fs::read(&live_data).unwrap();
let live_rows_before = live_row_count(&live_data, &schema);
assert_eq!(live_rows_before, 5, "sanity: live generation has 5 rows");
let orphan_tmp = temp_dir.path().join("data").join(".compaction-tmp-99");
std::fs::create_dir_all(orphan_tmp.join("test_ks").join("test_table")).unwrap();
std::fs::write(
orphan_tmp
.join("test_ks")
.join("test_table")
.join("nb-99-big-Data.db"),
b"partial",
)
.unwrap();
for comp in &["nb-99-big-Data.db", "nb-99-big-Index.db"] {
std::fs::write(dir.join(comp), b"orphan").unwrap();
}
let _engine = WriteEngine::new(config_for(&temp_dir)).unwrap();
assert!(
!orphan_tmp.exists(),
"orphan .compaction-tmp-99 must be swept"
);
assert!(
!dir.join("nb-99-big-Data.db").exists(),
"partial-rename orphan Data.db must be swept"
);
assert!(!dir.join("nb-99-big-Index.db").exists());
assert_eq!(
std::fs::read(&live_data).unwrap(),
live_bytes_before,
"live Data.db must be byte-for-byte untouched"
);
assert_eq!(
live_row_count(&live_data, &schema),
live_rows_before,
"live generation must still return its rows after the sweep"
);
}
#[test]
fn sweep_never_deletes_a_complete_generation() {
let temp_dir = TempDir::new().unwrap();
let dir = sstable_dir(&temp_dir);
std::fs::create_dir_all(&dir).unwrap();
let complete = [
"nb-2147483647-big-Data.db",
"nb-2147483647-big-Index.db",
"nb-2147483647-big-Statistics.db",
"nb-2147483647-big-Digest.crc32",
"nb-2147483647-big-TOC.txt",
];
for name in &complete {
std::fs::write(dir.join(name), b"complete").unwrap();
}
std::fs::write(dir.join("nb-not-a-generation.txt"), b"noise").unwrap();
let before = dir_names(&dir);
let _engine = WriteEngine::new(config_for(&temp_dir)).unwrap();
let after = dir_names(&dir);
assert_eq!(
before, after,
"a complete generation (TOC.txt present) must be left exactly as-is"
);
}
#[test]
fn compaction_tmp_sweep_ignores_non_matching_entries() {
let temp_dir = TempDir::new().unwrap();
let data_dir = temp_dir.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::write(data_dir.join(".compaction-tmp-7"), b"i am a file").unwrap();
std::fs::create_dir_all(data_dir.join("compaction-tmp-real")).unwrap();
let before = dir_names(&data_dir);
let outcome = WriteEngine::sweep_orphaned_compaction_tmp(&data_dir);
let after = dir_names(&data_dir);
assert!(
outcome.removed.is_empty(),
"nothing matched — nothing removed"
);
assert!(!outcome.had_failures());
assert_eq!(before, after, "non-matching entries must be untouched");
}
#[cfg(unix)]
#[test]
fn undeletable_orphan_is_non_fatal_and_surfaced() {
use std::os::unix::fs::PermissionsExt;
if !readonly_dir_blocks_delete() {
eprintln!("skipping: cannot exercise permission-denied path (root?)");
return;
}
let temp_dir = TempDir::new().unwrap();
let dir = sstable_dir(&temp_dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("nb-42-big-Data.db"), b"orphan").unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let outcome = WriteEngine::sweep_orphaned_partial_sstables(
temp_dir.path().join("data").as_path(),
"test_ks",
"test_table",
);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(
outcome.had_failures(),
"an un-removable orphan must be surfaced as a non-fatal failure"
);
assert!(
outcome.failures[0].contains("generation 42"),
"the surfaced failure must identify the orphan generation, got: {:?}",
outcome.failures
);
assert!(dir.join("nb-42-big-Data.db").exists());
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let opened = WriteEngine::new(config_for(&temp_dir));
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(
opened.is_ok(),
"engine startup must not abort on an un-removable orphan"
);
}
#[test]
fn sweeps_are_idempotent() {
let temp_dir = TempDir::new().unwrap();
let data_dir = temp_dir.path().join("data");
let dir = sstable_dir(&temp_dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(data_dir.join(".compaction-tmp-5")).unwrap();
std::fs::write(data_dir.join(".compaction-tmp-5").join("p.db"), b"x").unwrap();
std::fs::write(dir.join("nb-5-big-Data.db"), b"orphan").unwrap();
std::fs::write(dir.join("nb-5-big-Index.db"), b"orphan").unwrap();
let tmp1 = WriteEngine::sweep_orphaned_compaction_tmp(&data_dir);
let part1 =
WriteEngine::sweep_orphaned_partial_sstables(&data_dir, "test_ks", "test_table");
assert_eq!(
tmp1.removed.len(),
1,
"first tmp sweep removes the orphan dir"
);
assert_eq!(
part1.removed.len(),
1,
"first partial sweep removes the orphan"
);
assert!(!tmp1.had_failures() && !part1.had_failures());
let state_after_first = dir_names(&data_dir);
let tmp2 = WriteEngine::sweep_orphaned_compaction_tmp(&data_dir);
let part2 =
WriteEngine::sweep_orphaned_partial_sstables(&data_dir, "test_ks", "test_table");
assert!(tmp2.removed.is_empty(), "second tmp sweep removes nothing");
assert!(
part2.removed.is_empty(),
"second partial sweep removes nothing"
);
assert!(!tmp2.had_failures() && !part2.had_failures());
assert_eq!(
state_after_first,
dir_names(&data_dir),
"the second sweep must not change the directory"
);
}
#[test]
fn crash_mid_compaction_then_restart_sweeps_and_recompacts() {
use crate::storage::write_engine::compaction::FAIL_COMPACTION_BEFORE_RENAME;
let temp_dir = TempDir::new().unwrap();
let schema = create_test_schema();
let data_dir = temp_dir.path().join("data");
let mut engine = WriteEngine::new(config_for(&temp_dir)).unwrap();
let pre_paths = flush_n_sstables_sync(&mut engine, 4);
assert_eq!(pre_paths.len(), 4);
FAIL_COMPACTION_BEFORE_RENAME.with(|f| f.set(true));
let policy = crate::storage::write_engine::STCSPolicy::new(4, 32, 0.5, 1.5, 0).unwrap();
engine.set_merge_policy(Box::new(policy)).unwrap();
let crashed = engine.maintenance_step(Duration::from_secs(60));
FAIL_COMPACTION_BEFORE_RENAME.with(|f| f.set(false));
assert!(
crashed.is_err(),
"the injected pre-rename failure must surface as a compaction error"
);
let tmp_dirs: Vec<_> = std::fs::read_dir(&data_dir)
.unwrap()
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with(".compaction-tmp-")
})
.collect();
assert_eq!(
tmp_dirs.len(),
1,
"the crash must leave exactly one .compaction-tmp-* dir"
);
for p in &pre_paths {
assert!(p.exists(), "input {:?} must survive a failed compaction", p);
}
drop(engine);
let mut engine = WriteEngine::new(config_for(&temp_dir)).unwrap();
let leftover: Vec<_> = std::fs::read_dir(&data_dir)
.unwrap()
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with(".compaction-tmp-")
})
.collect();
assert!(
leftover.is_empty(),
"startup sweep must reclaim the orphaned tmp dir, found {:?}",
leftover.iter().map(|e| e.path()).collect::<Vec<_>>()
);
let total_pre: usize = pre_paths.iter().map(|p| live_row_count(p, &schema)).sum();
assert_eq!(
total_pre, 20,
"all 20 pre-compaction rows must still read back"
);
let policy = crate::storage::write_engine::STCSPolicy::new(4, 32, 0.5, 1.5, 0).unwrap();
engine.set_merge_policy(Box::new(policy)).unwrap();
let report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
assert_eq!(
report.completed_merges.len(),
1,
"the re-run compaction must complete one merge"
);
let merged = &report.completed_merges[0];
assert_eq!(
live_row_count(merged, &schema),
20,
"the recompacted output must contain all 20 rows"
);
}
}