fix(ecstore): fsync inline rename_data rollback backup (#4346)

* fix(ecstore): fsync inline rename_data rollback backup

The inline branch of LocalDisk::rename_data writes the previous version's
xl.meta rollback backup (<old_data_dir>/xl.meta.bkp) with a bare
std::fs::write, fsyncing neither the file nor its new directory entry, even
when drive_sync_enabled() is true. The same closure already syncs the new
xl.meta and the commit rename directory, and the non-inline branch persists
its backup durably.

That backup is the sole restore source for delete_version(undo_write=true)
on a set-level write-quorum failure. With sync enabled, an inline overwrite
plus power loss plus a quorum failure that triggers undo_write could restore
a lost or truncated backup and fail to roll back to the prior committed
object.

Write the inline backup via OpenOptions + write_all and, when sync is
enabled, sync_data() it and fsync_dir_std its parent, mirroring the
non-inline branch. Extend the inline backup test to assert the .bkp contents
equal the previous metadata bytes.

Refs backlog#868 (disk-durability-01).

* fix(ecstore): preserve to_file_error mapping on inline backup write

Address review: the rewritten inline rollback-backup write must keep the
same DiskError classification as the previous std::fs::write(...).map_err(
to_file_error)? call. Without it, io errors (PermissionDenied/NotFound/
StorageFull/...) would surface as DiskError::Io(_) instead of the specific
DiskError variants (FileAccessDenied/FileNotFound/DiskFull/...), since
From<io::Error> for DiskError only recovers variants that were wrapped via
to_file_error. Map open/write_all/sync_data/fsync_dir_std through
to_file_error, matching write_all_internal.

---------

Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
Zhengchao An
2026-07-07 11:47:43 +08:00
committed by GitHub
parent e4ecb1bce5
commit 0ce0388fc0
+32 -8
View File
@@ -3869,10 +3869,29 @@ impl DiskAPI for LocalDisk {
&& let Some(dst_parent) = dst.parent() && let Some(dst_parent) = dst.parent()
{ {
let old_path = dst_parent.join(old_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP); let old_path = dst_parent.join(old_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
if let Some(old_parent) = old_path.parent() { let old_parent = old_path.parent().map(|p| p.to_path_buf());
if let Some(ref old_parent) = old_parent {
std::fs::create_dir_all(old_parent)?; std::fs::create_dir_all(old_parent)?;
} }
std::fs::write(&old_path, buf).map_err(to_file_error)?; // This rollback backup is the sole restore source for a later
// undo_write when the set-level write quorum fails. Persist it as
// durably as the new xl.meta written above (and as the non-inline
// branch does): a bare std::fs::write leaves both the bytes and the
// new directory entry in the page cache, so a crash before a
// rollback could restore a lost or truncated backup.
let mut backup = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&old_path)
.map_err(to_file_error)?;
std::io::Write::write_all(&mut backup, buf).map_err(to_file_error)?;
if sync {
backup.sync_data().map_err(to_file_error)?;
if let Some(ref old_parent) = old_parent {
os::fsync_dir_std(old_parent).map_err(to_file_error)?;
}
}
} }
match std::fs::rename(&src, &dst) { match std::fs::rename(&src, &dst) {
@@ -4581,11 +4600,12 @@ mod test {
ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await; ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await;
let old_fi = test_file_info(object, version_id, Some(old_data_dir), None); let old_fi = test_file_info(object, version_id, Some(old_data_dir), None);
let old_meta = test_meta(old_fi);
let dst_object_dir = dir.path().join(bucket).join(object); let dst_object_dir = dir.path().join(bucket).join(object);
fs::create_dir_all(dst_object_dir.join(old_data_dir.to_string())) fs::create_dir_all(dst_object_dir.join(old_data_dir.to_string()))
.await .await
.expect("old data dir should be created"); .expect("old data dir should be created");
fs::write(dst_object_dir.join(STORAGE_FORMAT_FILE), test_meta(old_fi)) fs::write(dst_object_dir.join(STORAGE_FORMAT_FILE), &old_meta)
.await .await
.expect("old metadata should be written"); .expect("old metadata should be written");
@@ -4601,11 +4621,15 @@ mod test {
.expect("inline rename_data should commit"); .expect("inline rename_data should commit");
assert_eq!(resp.old_data_dir, Some(old_data_dir)); assert_eq!(resp.old_data_dir, Some(old_data_dir));
assert!( let backup_path = dst_object_dir.join(old_data_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
dst_object_dir assert!(backup_path.exists());
.join(old_data_dir.to_string()) // The rollback backup must contain the previous metadata bytes verbatim so
.join(STORAGE_FORMAT_FILE_BACKUP) // that undo_write can restore the prior committed object; guards the inline
.exists() // backup write against truncation/corruption regressions.
assert_eq!(
fs::read(&backup_path).await.expect("backup should be readable"),
old_meta,
"inline rollback backup must contain the previous metadata bytes verbatim"
); );
assert!( assert!(
!dst_object_dir !dst_object_dir