mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-18 18:46:17 +00:00
fix(heal): harden replacement rebuild tracking (#5892)
* fix(heal): gate auto replacement formatting Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): require replacement target outcomes Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): bind resumes to replacement targets Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence healing marker ownership Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover replacement target completion Co-Authored-By: heihutu <heihutu@gmail.com> * docs(heal): clarify replacement recovery status Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): canonicalize replacement target checks Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): satisfy marker test module lint Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): scope automatic replacement format Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): require a mounted replacement target Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): avoid cloned ref slice in test Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): revalidate replacement before scanning Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): reset stale resume checkpoints Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): release scanner disk map before probing Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): persist replacement intent before format Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fail closed on mountinfo read errors Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence replacement target identity Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): order replacement completion cleanup Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): atomically seal replacement completion Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): census replacement target shards Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence replacement recovery ownership Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): preserve replacement recovery anchors Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): satisfy replacement recovery lint gates Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): bind replacement identity to mount lease Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover durable replacement recovery states Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): validate persisted resume task identifiers Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): avoid blocking replacement marker CAS Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): report failed marker rollback Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): pin replacement resume schema compatibility Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): preserve durable recovery anchors Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): preserve public disk path semantics Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): use canonical replacement task ids Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover automatic replacement in 3x4 cluster Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): verify replacement target commits Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): persist replacement completion proof Co-Authored-By: heihutu <heihutu@gmail.com> * feat(heal): expose durable replacement status Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): bound durable replacement discovery Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): remove replacement readiness bypass Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): retry terminal replacement cleanup Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): isolate replacement intents from legacy resume Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): migrate legacy replacement intents at startup Co-Authored-By: heihutu <heihutu@gmail.com> * style(heal): apply strict clippy fix Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): prioritize active replacement recovery state Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): bind readiness to the admitted mount lease Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): atomically publish replacement intents Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): isolate replacement recovery directory Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): tolerate an empty recovery directory Co-Authored-By: heihutu <heihutu@gmail.com> * style(heal): remove redundant disk bytes conversion Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): reconcile proof-first replacement recovery Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence torn intent recovery Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover replacement migration conflicts Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): fence replacement lease mount identity Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover missing replacement path admission Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): reject conflicting legacy completion proof Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): fall back to proc mount identity Co-Authored-By: heihutu <heihutu@gmail.com> * feat(admin): expose replacement recovery status Surface the local durable replacement recovery snapshot in the background heal status response so operators can tell whether replacement cleanup is definitive or still pending. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): keep replacement status compatible Keep the existing background heal status response wire-compatible while retaining the Linux mount lease cleanup needed for the replacement recovery branch. Co-Authored-By: heihutu <heihutu@gmail.com> * style(ecstore): match linux mount lease formatting Keep Linux rustfmt output stable for the replacement mount lease comparison. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): qualify mount lease test constant Use the disk module path for the format config constant in the Linux mount lease regression test. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): keep procfd mount roots directory-safe Use a procfd path with an explicit directory component so Unix directory guards can open the replacement mount lease root with O_NOFOLLOW while preserving handle-relative I/O semantics. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): delete empty leased buckets via dirfd Use the held mount lease fd as the parent for non-force empty bucket deletion on Linux so procfd-rooted paths do not get rejected as BucketNotEmpty. Also make the download-part OpenOptions truncate behavior explicit and keep fsync test recording stable across procfd canonicalization. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): scan leased bucket paths for emptiness Use the local disk I/O root for bucket emptiness probes before non-force bucket deletion and table-bucket metadata checks. This keeps validation on the same mount instance as the subsequent local disk delete path. Co-Authored-By: heihutu <heihutu@gmail.com> * test(ecstore): align lease path test probes Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): block unsafe replacement recovery restarts Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): defer blocked replacement candidates Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): retry transient replacement discovery Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): keep transient recovery errors retryable Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): block corrupt legacy replacement state Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): classify flat replacement intent corruption Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): keep transient resume loads retryable Classify malformed legacy replacement state as blocking corruption while preserving disk and transient load failures for retry. This avoids permanently blocking replacement recovery on temporary storage errors. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): avoid latching transient legacy publishes Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): retry blocked legacy migrations Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): defer blocked startup recoveries Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): preserve disk sync limiter across lease roots Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com>
This commit is contained in:
@@ -152,6 +152,7 @@ const DISK_OPERATION_NAMES: &[&str] = &[
|
||||
"read_parts",
|
||||
"read_multiple",
|
||||
"write_all",
|
||||
"compare_and_update_file",
|
||||
"read_all",
|
||||
];
|
||||
|
||||
@@ -1092,6 +1093,18 @@ impl LocalDiskWrapper {
|
||||
self.disk.get_object_path(volume, path)
|
||||
}
|
||||
|
||||
pub(crate) fn get_object_path_for_io(&self, volume: &str, path: &str) -> crate::disk::error::Result<std::path::PathBuf> {
|
||||
self.disk.get_object_path_for_io(volume, path)
|
||||
}
|
||||
|
||||
pub(crate) fn get_bucket_path_for_io(&self, volume: &str) -> crate::disk::error::Result<std::path::PathBuf> {
|
||||
self.disk.get_bucket_path_for_io(volume)
|
||||
}
|
||||
|
||||
pub fn replacement_mount_lease_root(&self) -> Option<std::path::PathBuf> {
|
||||
self.disk.replacement_mount_lease_root()
|
||||
}
|
||||
|
||||
pub fn runtime_state(&self) -> RuntimeDriveHealthState {
|
||||
self.health.runtime_state()
|
||||
}
|
||||
@@ -1639,6 +1652,10 @@ impl LocalDiskWrapper {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiskAPI for LocalDiskWrapper {
|
||||
fn has_replacement_mount_lease(&self) -> bool {
|
||||
self.disk.has_replacement_mount_lease()
|
||||
}
|
||||
|
||||
async fn read_metadata(&self, volume: &str, path: &str) -> Result<Bytes> {
|
||||
self.track_disk_health_with_op_and_timeout_action(
|
||||
"read_metadata",
|
||||
@@ -2140,6 +2157,22 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn compare_and_update_file(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
expected: Option<Bytes>,
|
||||
replacement: Option<Bytes>,
|
||||
) -> Result<crate::disk::ConditionalFileUpdate> {
|
||||
self.track_disk_health_mutation(
|
||||
"compare_and_update_file",
|
||||
DiskMetricMutation::Write,
|
||||
|| async { self.disk.compare_and_update_file(volume, path, expected, replacement).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_all(&self, volume: &str, path: &str) -> Result<Bytes> {
|
||||
self.track_disk_health_with_op(
|
||||
"read_all",
|
||||
|
||||
+726
-156
File diff suppressed because it is too large
Load Diff
@@ -115,6 +115,15 @@ pub enum PartTransactionAction {
|
||||
Rollback,
|
||||
}
|
||||
|
||||
/// Result of an owner-aware file mutation. The disk applies the mutation only
|
||||
/// while the current contents match the supplied expected value.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ConditionalFileUpdate {
|
||||
Updated,
|
||||
Missing,
|
||||
Mismatch,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MmapCopyStageMetrics {
|
||||
pub(crate) path: &'static str,
|
||||
@@ -557,6 +566,26 @@ impl DiskAPI for Disk {
|
||||
}
|
||||
}
|
||||
|
||||
async fn compare_and_update_file(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
expected: Option<Bytes>,
|
||||
replacement: Option<Bytes>,
|
||||
) -> Result<ConditionalFileUpdate> {
|
||||
match self {
|
||||
Disk::Local(local_disk) => local_disk.compare_and_update_file(volume, path, expected, replacement).await,
|
||||
Disk::Remote(remote_disk) => remote_disk.compare_and_update_file(volume, path, expected, replacement).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_replacement_mount_lease(&self) -> bool {
|
||||
match self {
|
||||
Disk::Local(local_disk) => local_disk.has_replacement_mount_lease(),
|
||||
Disk::Remote(remote_disk) => remote_disk.has_replacement_mount_lease(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn read_all(&self, volume: &str, path: &str) -> Result<Bytes> {
|
||||
match self {
|
||||
@@ -695,6 +724,34 @@ impl Disk {
|
||||
Disk::Remote(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_object_path_for_io_if_local(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
) -> Option<crate::disk::error::Result<std::path::PathBuf>> {
|
||||
match self {
|
||||
Disk::Local(w) => Some(w.get_object_path_for_io(volume, path)),
|
||||
Disk::Remote(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_bucket_path_for_io_if_local(&self, volume: &str) -> Option<crate::disk::error::Result<std::path::PathBuf>> {
|
||||
match self {
|
||||
Disk::Local(w) => Some(w.get_bucket_path_for_io(volume)),
|
||||
Disk::Remote(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the descriptor-rooted mount path admitted for automatic
|
||||
/// replacement, or `None` when the configured endpoint no longer names
|
||||
/// that held mount instance.
|
||||
pub fn replacement_mount_lease_root(&self) -> Option<PathBuf> {
|
||||
match self {
|
||||
Disk::Local(local_disk) => local_disk.replacement_mount_lease_root(),
|
||||
Disk::Remote(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn new_disk(ep: &Endpoint, opt: &DiskOption) -> Result<DiskStore> {
|
||||
@@ -860,6 +917,24 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
|
||||
// CleanAbandonedData
|
||||
async fn write_all(&self, volume: &str, path: &str, data: Bytes) -> Result<()>;
|
||||
async fn read_all(&self, volume: &str, path: &str) -> Result<Bytes>;
|
||||
/// Atomically replace or remove a small control file only when its current
|
||||
/// contents match `expected`. Implementations that cannot provide this
|
||||
/// cross-process guarantee must fail closed instead of emulating it with a
|
||||
/// read-then-write sequence.
|
||||
async fn compare_and_update_file(
|
||||
&self,
|
||||
_volume: &str,
|
||||
_path: &str,
|
||||
_expected: Option<Bytes>,
|
||||
_replacement: Option<Bytes>,
|
||||
) -> Result<ConditionalFileUpdate> {
|
||||
Err(DiskError::MethodNotAllowed)
|
||||
}
|
||||
/// Whether local I/O is rooted at a held mount descriptor. Auto-replacement
|
||||
/// refuses destructive work when this is false.
|
||||
fn has_replacement_mount_lease(&self) -> bool {
|
||||
false
|
||||
}
|
||||
async fn disk_info(&self, opts: &DiskInfoOptions) -> Result<DiskInfo>;
|
||||
fn start_scan(&self) -> ScanGuard;
|
||||
}
|
||||
@@ -1612,6 +1687,7 @@ mod tests {
|
||||
|
||||
let endpoint = Endpoint::try_from(test_dir).unwrap();
|
||||
let local_disk = LocalDisk::new(&endpoint, false).await.unwrap();
|
||||
let expected_object_path = local_disk.root.join("test-bucket/test-object");
|
||||
let disk = Disk::Local(Box::new(LocalDiskWrapper::new(Arc::new(local_disk), false)));
|
||||
|
||||
// Test basic methods
|
||||
@@ -1626,6 +1702,19 @@ mod tests {
|
||||
// Test path method
|
||||
let path = disk.path();
|
||||
assert!(path.exists());
|
||||
let object_path = disk
|
||||
.get_object_path_if_local("test-bucket", "test-object")
|
||||
.expect("local disk should expose an object path")
|
||||
.expect("object path should resolve");
|
||||
assert_eq!(object_path, expected_object_path);
|
||||
assert!(!object_path.starts_with("/proc/self/fd/"));
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(
|
||||
disk.get_object_path_for_io_if_local("test-bucket", "test-object")
|
||||
.expect("local disk should expose an I/O object path")
|
||||
.expect("I/O object path should resolve")
|
||||
.starts_with("/proc/self/fd/")
|
||||
);
|
||||
|
||||
// Test disk location
|
||||
let location = disk.get_disk_location();
|
||||
|
||||
@@ -84,11 +84,22 @@ pub(crate) mod fsync_dir_recorder {
|
||||
static RECORDED: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
||||
|
||||
pub(crate) fn record(dir: &Path) {
|
||||
RECORDED.lock().expect("fsync dir recorder poisoned").push(dir.to_path_buf());
|
||||
let mut recorded = RECORDED.lock().expect("fsync dir recorder poisoned");
|
||||
recorded.push(dir.to_path_buf());
|
||||
if let Ok(canonical) = dir.canonicalize()
|
||||
&& canonical != dir
|
||||
{
|
||||
recorded.push(canonical);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn was_fsynced(dir: &Path) -> bool {
|
||||
RECORDED.lock().expect("fsync dir recorder poisoned").iter().any(|p| p == dir)
|
||||
let canonical = dir.canonicalize().ok();
|
||||
RECORDED
|
||||
.lock()
|
||||
.expect("fsync dir recorder poisoned")
|
||||
.iter()
|
||||
.any(|p| p == dir || canonical.as_ref().is_some_and(|canonical| p == canonical))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +341,7 @@ pub(crate) mod file_sync_probe {
|
||||
use tokio::sync::Notify;
|
||||
use tokio::time::timeout;
|
||||
|
||||
static ROOT: RwLock<Option<PathBuf>> = RwLock::new(None);
|
||||
static ROOTS: RwLock<Vec<PathBuf>> = RwLock::new(Vec::new());
|
||||
static BLOCK_MUTEX: Mutex<()> = Mutex::new(());
|
||||
static BLOCK_CONDVAR: Condvar = Condvar::new();
|
||||
static ACTIVE_CHANGED: Notify = Notify::const_new();
|
||||
@@ -364,7 +375,7 @@ pub(crate) mod file_sync_probe {
|
||||
fn drop(&mut self) {
|
||||
release();
|
||||
FAIL_ON_ATTEMPT.store(usize::MAX, Ordering::SeqCst);
|
||||
*ROOT.write().expect("file sync probe lock poisoned") = None;
|
||||
ROOTS.write().expect("file sync probe lock poisoned").clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +388,13 @@ pub(crate) mod file_sync_probe {
|
||||
let _guard = BLOCK_MUTEX.lock().expect("file sync probe blocker poisoned");
|
||||
BLOCK.store(block, Ordering::SeqCst);
|
||||
}
|
||||
*ROOT.write().expect("file sync probe lock poisoned") = Some(root.to_path_buf());
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
if let Ok(canonical) = root.canonicalize()
|
||||
&& canonical != root
|
||||
{
|
||||
roots.push(canonical);
|
||||
}
|
||||
*ROOTS.write().expect("file sync probe lock poisoned") = roots;
|
||||
ProbeGuard
|
||||
}
|
||||
|
||||
@@ -398,11 +415,11 @@ pub(crate) mod file_sync_probe {
|
||||
}
|
||||
|
||||
pub(super) fn enter(path: &Path) -> Option<ActiveGuard> {
|
||||
let enabled = ROOT
|
||||
.read()
|
||||
.expect("file sync probe lock poisoned")
|
||||
.as_ref()
|
||||
.is_some_and(|root| path.starts_with(root));
|
||||
let canonical_path = path.canonicalize().ok();
|
||||
let enabled =
|
||||
ROOTS.read().expect("file sync probe lock poisoned").iter().any(|root| {
|
||||
path.starts_with(root) || canonical_path.as_ref().is_some_and(|canonical| canonical.starts_with(root))
|
||||
});
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user