mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-09 06:39:25 +00:00
fix(ecstore): purge the stale destination data dir on healing rename_data commits (#5822)
* fix(ecstore): purge the stale destination data dir on healing rename_data commits Heal commits reuse the version's existing data_dir, so when repairing in-place corruption (bitrot) the destination directory still exists and holds the corrupt shard files. rename(2) cannot replace a non-empty directory (EEXIST on XFS, ENOTEMPTY on ext4), so the commit failed on every attempt — including all scheduler retries — and in-place bitrot was detected and reconstructed but never repaired. Purge the stale destination data dir (move_to_trash) before the commit rename, for healing commits only: fresh PUTs mint a new data_dir and can never collide, and a non-healing collision keeps failing loudly. Adds the FileInfo::is_healing() reader for the marker set_healing() already writes. * style(ecstore): emit the heal purge failure as a structured event The new warning was the only sentence-style log in `rename_data`'s commit path — it sat ten lines above `info!(event = EVENT_DISK_LOCAL_RENAME_REJECTED, component = ..., subsystem = ...)` and interpolated its values into the message instead of carrying them as fields, so it is invisible to any operator query keyed on `event`. Give it the shape the rest of the file uses: a named `EVENT_DISK_LOCAL_HEAL_PURGE_FAILED`, `component`/`subsystem`, `dst_path` and `error` as fields, and a short label as the message. Level stays `warn` — the purge is best effort and the rename below fails closed — and the condition, the branch, and the control flow are unchanged. --------- Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
This commit is contained in:
@@ -691,6 +691,10 @@ const EVENT_DISK_LOCAL_CHECK_PARTS: &str = "disk_local_check_parts";
|
||||
const EVENT_DISK_LOCAL_ACCESS_FAILED: &str = "disk_local_access_failed";
|
||||
const EVENT_DISK_LOCAL_VOLUME_SETUP_FAILED: &str = "disk_local_volume_setup_failed";
|
||||
const EVENT_DISK_LOCAL_FORMAT_DECODE_FAILED: &str = "disk_local_format_decode_failed";
|
||||
/// A healing commit could not trash the stale destination data dir it is about
|
||||
/// to replace. Best effort — the rename that follows fails closed — but a
|
||||
/// recurring signal means heal is stuck on that drive.
|
||||
const EVENT_DISK_LOCAL_HEAL_PURGE_FAILED: &str = "disk_local_heal_purge_failed";
|
||||
const METRIC_GET_OBJECT_MMAP_PAGE_FAULTS_TOTAL: &str = "rustfs_io_get_object_mmap_page_faults_total";
|
||||
const METRIC_GET_OBJECT_DIRECT_READ_PAGE_FAULTS_TOTAL: &str = "rustfs_io_get_object_direct_read_page_faults_total";
|
||||
// io_uring read-backend gray-release observability (rustfs/backlog#1172).
|
||||
@@ -7853,6 +7857,9 @@ impl DiskAPI for LocalDisk {
|
||||
check_path_length(dst_file_path.to_string_lossy().to_string().as_str())?;
|
||||
|
||||
let no_inline = fi.data.is_none() && fi.size > 0;
|
||||
// Captured before `fi` is consumed by add_version; gates the stale
|
||||
// destination purge below.
|
||||
let fi_healing = fi.is_healing();
|
||||
|
||||
// Resolved once for the whole commit so a concurrent configuration
|
||||
// change can never leave a single rename_data half-synced. The tier is
|
||||
@@ -7969,6 +7976,26 @@ impl DiskAPI for LocalDisk {
|
||||
shard_sync_res?;
|
||||
remove_dst_base_before_commit(dst_path).map_err(to_file_error)?;
|
||||
|
||||
// Heal reuses the version's data_dir, so for in-place corruption
|
||||
// the destination dir still exists — and rename(2) cannot replace
|
||||
// a non-empty directory (EEXIST on XFS, ENOTEMPTY on ext4). Purge
|
||||
// it first, healing commits only; fresh PUTs mint a new data_dir
|
||||
// and never collide. Best effort: a real failure surfaces in the
|
||||
// rename below.
|
||||
if fi_healing
|
||||
&& let Some((_, dst_data_path)) = has_data_dir_path.as_ref()
|
||||
&& let Err(err) = self.move_to_trash(dst_data_path, true, false).await
|
||||
{
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_HEAL_PURGE_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
dst_path = ?dst_data_path,
|
||||
error = ?err,
|
||||
"Healing commit could not purge the stale destination data dir"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref()
|
||||
&& let Err(err) = rename_all(src_data_path, dst_data_path, &skip_parent).await
|
||||
{
|
||||
@@ -11227,6 +11254,86 @@ mod test {
|
||||
(disk, dir)
|
||||
}
|
||||
|
||||
// Stage the bitrot-heal collision: a committed version whose data_dir is
|
||||
// present and non-empty, plus a replacement shard staged in tmp for the
|
||||
// SAME data_dir (heal repairs in place, it does not mint a new data_dir).
|
||||
async fn stage_healing_collision(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
tmp_object: &str,
|
||||
) -> (LocalDisk, tempfile::TempDir, std::path::PathBuf, FileInfo) {
|
||||
use tempfile::tempdir;
|
||||
let dir = tempdir().expect("temp dir should be created");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||
ensure_test_volume(&disk, bucket).await;
|
||||
ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await;
|
||||
|
||||
let version_id = Uuid::parse_str("dddddddd-dddd-dddd-dddd-dddddddddddd").expect("version id should parse");
|
||||
let data_dir = Uuid::parse_str("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee").expect("data dir should parse");
|
||||
|
||||
let object_dir = dir.path().join(bucket).join(object);
|
||||
let dst_data_dir = object_dir.join(data_dir.to_string());
|
||||
fs::create_dir_all(&dst_data_dir)
|
||||
.await
|
||||
.expect("dst data dir should be created");
|
||||
fs::write(dst_data_dir.join("part.1"), b"stale-corrupt-shard")
|
||||
.await
|
||||
.expect("stale shard should be written");
|
||||
let old_fi = test_file_info(object, version_id, Some(data_dir), None);
|
||||
fs::write(object_dir.join(STORAGE_FORMAT_FILE), test_meta(old_fi))
|
||||
.await
|
||||
.expect("old metadata should be written");
|
||||
|
||||
let tmp_data_dir = dir
|
||||
.path()
|
||||
.join(RUSTFS_META_TMP_BUCKET)
|
||||
.join(tmp_object)
|
||||
.join(data_dir.to_string());
|
||||
fs::create_dir_all(&tmp_data_dir)
|
||||
.await
|
||||
.expect("tmp data dir should be created");
|
||||
fs::write(tmp_data_dir.join("part.1"), b"healed-shard")
|
||||
.await
|
||||
.expect("healed shard should be written");
|
||||
|
||||
let new_fi = test_file_info(object, version_id, Some(data_dir), None);
|
||||
(disk, dir, dst_data_dir.join("part.1"), new_fi)
|
||||
}
|
||||
|
||||
// A healing commit must replace a still-existing destination data dir;
|
||||
// without the purge it failed on every attempt and bitrot was never
|
||||
// repaired.
|
||||
#[tokio::test]
|
||||
async fn rename_data_healing_commit_replaces_stale_destination_data_dir() {
|
||||
let (disk, _dir, dst_part, mut new_fi) = stage_healing_collision("bucket", "bitrot-object", "tmp-heal-object").await;
|
||||
new_fi.set_healing();
|
||||
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, "tmp-heal-object", new_fi, "bucket", "bitrot-object")
|
||||
.await
|
||||
.expect("a healing rename_data must replace the stale destination data dir");
|
||||
|
||||
let content = fs::read(&dst_part).await.expect("healed shard should be readable");
|
||||
assert_eq!(content, b"healed-shard", "the healed shard must replace the stale corrupt content");
|
||||
}
|
||||
|
||||
// The purge is healing-gated: an ordinary commit colliding with a
|
||||
// non-empty data dir must keep failing loudly.
|
||||
#[tokio::test]
|
||||
async fn rename_data_non_healing_destination_collision_still_fails() {
|
||||
let (disk, _dir, dst_part, new_fi) = stage_healing_collision("bucket", "collision-object", "tmp-collision-object").await;
|
||||
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, "tmp-collision-object", new_fi, "bucket", "collision-object")
|
||||
.await
|
||||
.expect_err("a non-healing rename_data onto a non-empty destination data dir must fail");
|
||||
|
||||
let content = fs::read(&dst_part).await.expect("stale shard should still be readable");
|
||||
assert_eq!(
|
||||
content, b"stale-corrupt-shard",
|
||||
"a failed non-healing commit must leave the existing content untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rename_data_new_object_fsyncs_new_ancestor_dirs() {
|
||||
// A first PUT under a new prefix must fsync every newly created ancestor
|
||||
|
||||
@@ -1111,6 +1111,12 @@ impl FileInfo {
|
||||
insert_str(&mut self.metadata, SUFFIX_HEALING, "true".to_string());
|
||||
}
|
||||
|
||||
/// Reader for the marker [`Self::set_healing`] writes: true when this
|
||||
/// FileInfo is being committed by the heal path.
|
||||
pub fn is_healing(&self) -> bool {
|
||||
contains_key_str(&self.metadata, SUFFIX_HEALING)
|
||||
}
|
||||
|
||||
pub fn set_tier_free_version_id(&mut self, version_id: &str) {
|
||||
insert_str(&mut self.metadata, SUFFIX_TIER_FV_ID, version_id.to_string());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user