feat(heal): disk-walk UNION enumeration to heal sub-quorum versions (backlog#920) (#4527)

B5 switched heal enumeration to list_object_versions, which only reflects the
read-quorum metadata view: a version present on fewer than read-quorum disks was
never enumerated, so it was never healed. Add a per-erasure-set disk-walk UNION
enumerator (mirrors MinIO global-heal.go objQuorum=1 listPathRaw +
mergeXLV2Versions) that surfaces every (object, version) present on ANY disk and
feeds each to the existing per-version heal_object.

- filemeta: MetaCacheEntries::resolve_union (dir_quorum=1/obj_quorum=1) yields the
  cross-disk version union at one tested seam.
- ecstore: SetDisks::heal_walk_versions_page (list_path_raw fan-out, min_disks=1,
  dual object/version page bound, inclusive-forward de-overlap) + ECStore delegator
  + HealWalkVersion.
- ecstore data-safety guard: before dangling-delete, try_regenerate_recoverable_meta
  physically probes part files via check_parts; when >= data_blocks data shards
  survive (meta lost but data recoverable) it regenerates xl.meta from a surviving
  FileInfo with the correct per-disk shard index instead of dangling-deleting.
  Genuine torn writes (< data_blocks) keep the current behavior — no resurrection.
- heal: dw1: forward-marker cursor codec (reuses ResumeState.resume_cursor,
  idempotent restart on foreign tokens); list_versions_for_heal_page_disk_walk
  trait method (default falls back to the B5 read-quorum path); heal_bucket_with_resume
  selects the disk-walk enumerator when scan_mode==Deep || source==AutoHeal, else
  the unchanged B5 path; anti-loop guard aborts on (empty && truncated).

Closes rustfs/backlog#920
This commit is contained in:
Zhengchao An
2026-07-09 01:07:54 +08:00
committed by GitHub
parent 2055044cb4
commit 3531abb34a
12 changed files with 1515 additions and 6 deletions
+1
View File
@@ -387,6 +387,7 @@ pub mod store_list {
}
pub mod storage {
pub use crate::store::HealWalkVersion;
pub use crate::store::{
ECStore, all_local_disk, all_local_disk_path, find_local_disk_by_ref, init_local_disks, init_lock_clients,
prewarm_local_disk_id_map,
+2
View File
@@ -495,6 +495,8 @@ mod read;
mod replication;
pub(crate) mod shard_source;
pub use ops::heal_walk::HealWalkVersion;
/// Get lock acquire timeout from environment variable RUSTFS_LOCK_ACQUIRE_TIMEOUT (in seconds)
/// Defaults to 30 seconds if not set or invalid
/// Lock acquisition timeout. Cached: this is consulted on every object
+171
View File
@@ -28,6 +28,23 @@ impl SetDisks {
object: &str,
version_id: &str,
opts: &HealOpts,
) -> disk::error::Result<(HealResultItem, Option<DiskError>)> {
// `allow_meta_regen` is true on the first pass: a version whose data shards
// physically survive (>= data_blocks) but whose xl.meta fell below
// read-quorum is RESCUED (missing xl.meta regenerated) rather than
// dangling-deleted. The re-drive after a rescue sets it false so the
// regeneration can happen at most once (no unbounded recursion).
Box::pin(self.heal_object_with_regen(bucket, object, version_id, opts, true)).await
}
#[allow(clippy::too_many_lines)]
async fn heal_object_with_regen(
&self,
bucket: &str,
object: &str,
version_id: &str,
opts: &HealOpts,
allow_meta_regen: bool,
) -> disk::error::Result<(HealResultItem, Option<DiskError>)> {
info!(?opts, "Starting heal_object");
@@ -213,6 +230,22 @@ impl SetDisks {
}
}
// DATA-SAFETY GUARD (backlog#920, decision 1): before any
// dangling delete, if the version's DATA shards physically
// survive on >= data_blocks disks it is RECONSTRUCTABLE.
// Regenerate the missing xl.meta from a surviving valid
// FileInfo and re-drive the heal instead of destroying a
// recoverable version. Torn writes (< data_blocks data
// shards) fall through to the existing dangling behavior.
if cannot_heal
&& allow_meta_regen
&& self
.try_regenerate_recoverable_meta(bucket, object, &parts_metadata, &errs, &disks)
.await?
{
return Box::pin(self.heal_object_with_regen(bucket, object, version_id, opts, false)).await;
}
if cannot_heal {
let total_disks = parts_metadata.len();
let healthy_count = total_disks.saturating_sub(disks_to_heal_count);
@@ -644,6 +677,19 @@ impl SetDisks {
}
}
Err(err) => {
// DATA-SAFETY GUARD (backlog#920, decision 1): meta quorum failed,
// but the version's DATA may still physically survive on enough
// disks (xl.meta lost on > parity disks while part files remain).
// Rescue it by regenerating the missing xl.meta and re-driving heal
// instead of dangling-deleting a reconstructable version.
if allow_meta_regen
&& self
.try_regenerate_recoverable_meta(bucket, object, &parts_metadata, &errs, &disks)
.await?
{
return Box::pin(self.heal_object_with_regen(bucket, object, version_id, opts, false)).await;
}
let data_errs_by_part = HashMap::new();
match self
.delete_if_dangling(
@@ -677,6 +723,131 @@ impl SetDisks {
}
}
/// backlog#920 (decision 1): rescue a version that meta-quorum logic would
/// otherwise dangling-DELETE, when its DATA is still reconstructable.
///
/// Returns `Ok(true)` if the version was rescued (missing xl.meta regenerated
/// on at least one disk, so a re-driven heal can reconstruct it), `Ok(false)`
/// to fall through to the existing dangling-delete behavior.
///
/// Recoverability is computed by physically probing part files across ALL
/// disks in the set with `check_parts` — including disks whose xl.meta is
/// absent (a lost xl.meta does not lose the sibling `part.*` data). If at
/// least `data_blocks` disks hold every part of a surviving valid FileInfo,
/// the object is EC-reconstructable, so we regenerate that FileInfo's xl.meta
/// on every disk whose metadata is absent (via `write_metadata`, which merges
/// into any existing xl.meta). Delete markers, remote/transitioned versions,
/// and genuine torn writes (< `data_blocks` surviving data shards) are NOT
/// rescued — they keep the current dangling-delete-after-grace behavior, so no
/// regression on those paths.
async fn try_regenerate_recoverable_meta(
&self,
bucket: &str,
object: &str,
parts_metadata: &[FileInfo],
errs: &[Option<DiskError>],
disks: &[Option<DiskStore>],
) -> disk::error::Result<bool> {
// A surviving valid, non-deleted, non-remote data FileInfo to rebuild from.
let Some(surviving) = parts_metadata
.iter()
.find(|fi| fi.is_valid() && !fi.deleted && !fi.is_remote())
.cloned()
else {
return Ok(false);
};
// Without a data_dir + parts there is no data to prove recoverable.
if surviving.data_dir.is_none() || surviving.parts.is_empty() {
return Ok(false);
}
let data_blocks = surviving.erasure.data_blocks;
if data_blocks == 0 {
return Ok(false);
}
// Physically probe part presence on EVERY online disk using the surviving
// FileInfo's data_dir/parts. `check_parts` stats `object/<data_dir>/part.N`
// directly, so it counts disks that still hold the data even if their
// xl.meta was deleted.
let mut available = 0usize;
for disk in disks.iter().flatten() {
if let Ok(resp) = disk.check_parts(bucket, object, &surviving).await
&& !resp.results.is_empty()
&& resp.results.iter().all(|r| *r == CHECK_PART_SUCCESS)
{
available += 1;
}
}
// Torn write: fewer than data_blocks surviving data shards is genuinely
// unrecoverable — preserve the current dangling behavior (no resurrection).
if available < data_blocks {
debug!(
bucket,
object,
available,
data_blocks,
"heal_object: version not reconstructable (torn write), keeping dangling behavior"
);
return Ok(false);
}
// Reconstructable: regenerate the surviving xl.meta on every disk whose
// metadata is absent so the version regains read-quorum. Each disk gets its
// OWN shard index: the disk at physical position `index` holds shard
// `distribution[index]` (mirrors `shuffle_disks` + the write path's
// `erasure.index = shuffled_pos + 1`). Copying the surviving disk's index
// verbatim would write an inconsistent xl.meta that the re-heal then treats
// as corrupt.
let distribution = &surviving.erasure.distribution;
let mut wrote = 0usize;
for (index, disk) in disks.iter().enumerate() {
let Some(disk) = disk else { continue };
let meta_absent = matches!(
errs.get(index).and_then(Option::as_ref),
Some(DiskError::FileNotFound | DiskError::FileVersionNotFound)
) || !parts_metadata.get(index).map(FileInfo::is_valid).unwrap_or(false);
if !meta_absent {
continue;
}
// Without a known shard index for this position we cannot write a
// consistent xl.meta; leave it for the normal heal to reconstruct.
let Some(&shard_index) = distribution.get(index) else {
continue;
};
let mut regen = surviving.clone();
regen.fresh = false; // merge into any existing xl.meta on the disk
regen.erasure.index = shard_index;
match disk.write_metadata("", bucket, object, regen).await {
Ok(()) => wrote += 1,
Err(e) => {
warn!(
bucket,
object,
disk_index = index,
error = %e,
"heal_object: failed to regenerate recoverable xl.meta on disk"
);
}
}
}
if wrote == 0 {
return Ok(false);
}
info!(
bucket,
object,
available,
data_blocks,
regenerated_meta_disks = wrote,
"heal_object: rescued reconstructable sub-quorum version by regenerating xl.meta"
);
Ok(true)
}
pub(in crate::set_disk) async fn heal_object_dir_locked(
&self,
bucket: &str,
@@ -0,0 +1,306 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Per-erasure-set disk-walk UNION enumerator for heal (backlog#920).
//!
//! B5's heal enumeration lists versions through the READ-QUORUM metadata view
//! (`list_object_versions`), so a version present on FEWER than read-quorum disks
//! is never enumerated and therefore never healed. This module walks each disk in
//! the set directly (mirroring MinIO cmd/global-heal.go `healErasureSet`:
//! `listPathRaw` with `objQuorum = 1` feeding `mergeXLV2Versions`) and surfaces
//! every `(object, version)` present on ANY disk, feeding each to the existing
//! per-version `SetDisks::heal_object`.
use super::super::*;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
/// A single `(object, version)` unit surfaced by the disk-walk union enumerator.
///
/// `is_delete_marker` is OBSERVABILITY-ONLY (metrics / logging / e2e assertions);
/// it must not gate healing logic — the delete-marker vs data path is chosen
/// inside `ops/heal.rs` from the resolved latest metadata. `version_id` is
/// normalized (nil/absent UUID => `None`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealWalkVersion {
/// object key
pub name: String,
/// normalized version id (`None` when the version is nil/absent)
pub version_id: Option<String>,
/// whether this version is a delete marker (observability only)
pub is_delete_marker: bool,
}
/// One collected object (a single sorted key) with all its expanded versions.
#[derive(Debug, Clone)]
struct HealWalkObject {
name: String,
versions: Vec<HealWalkVersion>,
}
/// Shared collector fed by the `agreed`/`partial` callbacks of `list_path_raw`.
/// `list_path_raw` emits at most one callback per distinct object key in sorted
/// order, so each successful ingest corresponds to exactly one new object.
struct HealWalkCollector {
bucket: String,
batch_objects: usize,
version_budget: usize,
objects: Mutex<Vec<HealWalkObject>>,
version_total: AtomicUsize,
truncated: AtomicBool,
cancel: CancellationToken,
}
impl HealWalkCollector {
/// Expand one resolved entry into its versions and record it. Cancels the
/// walk once EITHER page bound (distinct object names OR expanded versions)
/// is met — always at a sorted object-key boundary so a heavily-versioned
/// object is never split across pages.
fn ingest(&self, entry: MetaCacheEntry) {
// Skip pure directory entries; they carry no versions to heal here.
if entry.is_dir() {
return;
}
// Expand to one HealWalkVersion per FileInfo. KEEP remote/transitioned and
// free-version records: their LOCAL xl.meta may still need healing.
let fiv = match entry.file_info_versions_with_free_versions(&self.bucket) {
Ok(fiv) => fiv,
Err(err) => {
debug!(entry = %entry.name, error = ?err, "heal disk-walk skipped entry with unreadable versions");
return;
}
};
let mut versions = Vec::with_capacity(fiv.versions.len() + fiv.free_versions.len());
for fi in fiv.versions.iter().chain(fiv.free_versions.iter()) {
versions.push(HealWalkVersion {
name: entry.name.clone(),
// Normalize: nil/absent version id => None.
version_id: fi.version_id.filter(|u| !u.is_nil()).map(|u| u.to_string()),
is_delete_marker: fi.deleted,
});
}
if versions.is_empty() {
return;
}
let added = versions.len();
let (objs_len, ver_total) = {
let mut objects = self.objects.lock().unwrap();
objects.push(HealWalkObject {
name: entry.name,
versions,
});
let objs_len = objects.len();
let ver_total = self.version_total.fetch_add(added, Ordering::SeqCst) + added;
(objs_len, ver_total)
};
// Bound at an object boundary (this object is fully included).
if objs_len >= self.batch_objects || ver_total >= self.version_budget {
self.truncated.store(true, Ordering::SeqCst);
self.cancel.cancel();
}
}
}
/// Finalize a collected disk-walk page: compute the resume cursor and apply
/// inclusive-forward de-overlap.
///
/// `next_forward` is derived from the PRE-de-overlap last collected object name
/// (the walk's `forward_to` is inclusive, so the next page re-reads that exact
/// key and drops it here). Leading objects whose name == `forward_to` are dropped
/// to avoid re-healing the boundary object.
fn finalize_heal_walk_page(
mut objects: Vec<HealWalkObject>,
forward_to: Option<&str>,
truncated: bool,
) -> (Vec<HealWalkVersion>, Option<String>, bool) {
let next_forward = objects.last().map(|o| o.name.clone());
if let Some(fw) = forward_to {
while objects.first().map(|o| o.name.as_str()) == Some(fw) {
objects.remove(0);
}
}
let versions: Vec<HealWalkVersion> = objects.into_iter().flat_map(|o| o.versions).collect();
if truncated {
(versions, next_forward, true)
} else {
(versions, None, false)
}
}
impl SetDisks {
/// Walk every disk in this set and return one page of the cross-disk UNION of
/// versions (see module docs). Returns `(versions, next_forward, truncated)`.
///
/// - `batch_objects` (>= 2) and `version_budget` are a DUAL page bound: the
/// walk stops at the first sorted object-key boundary where EITHER the
/// distinct-object-name count reaches `batch_objects` or the expanded
/// version count reaches `version_budget`.
/// - `forward_to` resumes the walk (inclusive); the boundary object is
/// de-overlapped here so no version is healed twice across pages.
///
/// `min_disks: 1` means the page is produced from WHATEVER disks respond, so a
/// version on a single surviving disk is still surfaced.
pub(crate) async fn heal_walk_versions_page(
&self,
bucket: &str,
prefix: &str,
forward_to: Option<&str>,
batch_objects: usize,
version_budget: usize,
) -> disk::error::Result<(Vec<HealWalkVersion>, Option<String>, bool)> {
assert!(batch_objects >= 2, "heal_walk_versions_page requires batch_objects >= 2");
let disks = self.get_disks_internal().await;
let collector = Arc::new(HealWalkCollector {
bucket: bucket.to_string(),
batch_objects,
version_budget: version_budget.max(1),
objects: Mutex::new(Vec::new()),
version_total: AtomicUsize::new(0),
truncated: AtomicBool::new(false),
cancel: CancellationToken::new(),
});
let agreed_collector = collector.clone();
let partial_collector = collector.clone();
let partial_bucket = bucket.to_string();
let filter_prefix = if prefix.is_empty() { None } else { Some(prefix.to_string()) };
let opts = ListPathRawOptions {
disks,
bucket: bucket.to_string(),
path: String::new(),
recursive: true,
incl_deleted: true,
filter_prefix,
forward_to: forward_to.map(str::to_string),
min_disks: 1,
report_not_found: false,
per_disk_limit: 0,
skip_walkdir_total_timeout: true,
agreed: Some(Box::new(move |entry: MetaCacheEntry| {
let collector = agreed_collector.clone();
Box::pin(async move {
collector.ingest(entry);
})
})),
partial: Some(Box::new(move |entries: MetaCacheEntries, _errs: &[Option<DiskError>]| {
let collector = partial_collector.clone();
let bucket = partial_bucket.clone();
Box::pin(async move {
// objQuorum = 1: take the cross-disk union of every version on
// any disk. Fall back to the first present entry if the merge
// somehow yields nothing.
let entry = entries.resolve_union(&bucket).or_else(|| entries.first_found().0);
if let Some(entry) = entry {
collector.ingest(entry);
}
})
})),
finished: None,
..Default::default()
};
// Drive the walk. A tolerated missing-path / not-found is treated as an
// empty page rather than an error (nothing to heal on this prefix).
match list_path_raw(collector.cancel.clone(), opts).await {
Ok(()) => {}
Err(DiskError::FileNotFound) | Err(DiskError::VolumeNotFound) => {
debug!(bucket, prefix, "heal disk-walk treated missing path as empty page");
}
Err(err) => return Err(err),
}
let objects = std::mem::take(&mut *collector.objects.lock().unwrap());
let truncated = collector.truncated.load(Ordering::SeqCst);
Ok(finalize_heal_walk_page(objects, forward_to, truncated))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn version(name: &str, id: &str, dm: bool) -> HealWalkVersion {
HealWalkVersion {
name: name.to_string(),
version_id: Some(id.to_string()),
is_delete_marker: dm,
}
}
fn object_with_versions(name: &str, count: usize) -> HealWalkObject {
HealWalkObject {
name: name.to_string(),
versions: (0..count).map(|i| version(name, &format!("{name}-v{i}"), false)).collect(),
}
}
/// A single heavily-versioned object whose version count exceeds the budget
/// is returned WHOLE in one page (never split), and the page is flagged
/// truncated with a resume cursor pointing at that object.
#[test]
fn union_page_bounded_by_version_budget_on_heavily_versioned_object() {
let big = object_with_versions("big.bin", 50);
// The collector cancels AFTER completing the object, so the buffer holds
// exactly this one object even though 50 >> the version budget of 10.
let (versions, next_forward, truncated) = finalize_heal_walk_page(vec![big], None, true);
assert_eq!(versions.len(), 50, "the heavily-versioned object must not be split across pages");
assert!(truncated, "exceeding the version budget must mark the page truncated");
assert_eq!(
next_forward.as_deref(),
Some("big.bin"),
"resume cursor must point at the boundary object"
);
}
/// Inclusive-forward de-overlap: the boundary object re-read on the next page
/// (name == forward_to) is dropped, so its versions are not healed twice.
#[test]
fn union_page_de_overlaps_inclusive_forward_boundary() {
let a = object_with_versions("a.bin", 2);
let b = object_with_versions("b.bin", 3);
// forward_to == "a.bin": the walk re-reads a.bin (inclusive) then b.bin.
let (versions, next_forward, truncated) = finalize_heal_walk_page(vec![a, b], Some("a.bin"), true);
assert!(
versions.iter().all(|v| v.name == "b.bin"),
"the boundary object a.bin must be de-overlapped, got {versions:?}"
);
assert_eq!(versions.len(), 3);
assert_eq!(next_forward.as_deref(), Some("b.bin"));
assert!(truncated);
}
/// A non-truncated final page clears the resume cursor.
#[test]
fn union_page_non_truncated_clears_cursor() {
let a = object_with_versions("a.bin", 1);
let (versions, next_forward, truncated) = finalize_heal_walk_page(vec![a], None, false);
assert_eq!(versions.len(), 1);
assert_eq!(next_forward, None, "a complete final page must not carry a resume cursor");
assert!(!truncated);
}
}
+1
View File
@@ -19,6 +19,7 @@
pub(crate) mod bucket;
pub(crate) mod heal;
pub(crate) mod heal_walk;
pub(crate) mod list;
pub(crate) mod locking;
pub(crate) mod multipart;
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Store-level entry point for the per-erasure-set disk-walk UNION heal
//! enumerator (backlog#920). Bounds-checks `(pool_idx, set_idx)` (mirroring
//! `handle_get_disks`) and delegates to the target `SetDisks`.
use super::*;
pub use crate::set_disk::HealWalkVersion;
impl ECStore {
/// Walk a specific erasure set's disks and return one page of the cross-disk
/// UNION of versions (see `SetDisks::heal_walk_versions_page`). `pool_idx` and
/// `set_idx` are bounds-checked against the live pool topology.
#[instrument(level = "debug", skip(self))]
#[allow(clippy::too_many_arguments)]
pub async fn heal_walk_versions_page(
&self,
pool_idx: usize,
set_idx: usize,
bucket: &str,
prefix: &str,
forward_to: Option<&str>,
batch_objects: usize,
version_budget: usize,
) -> Result<(Vec<HealWalkVersion>, Option<String>, bool)> {
if pool_idx >= self.pools.len() || set_idx >= self.pools[pool_idx].disk_set.len() {
return Err(Error::other(format!(
"heal disk-walk: invalid erasure set (pool index {pool_idx}, set index {set_idx}, pool count {})",
self.pools.len()
)));
}
self.pools[pool_idx].disk_set[set_idx]
.heal_walk_versions_page(bucket, prefix, forward_to, batch_objects, version_budget)
.await
.map_err(Error::from)
}
}
+2
View File
@@ -150,6 +150,8 @@ const MAX_UPLOADS_LIST: usize = 10000;
mod bucket;
mod heal;
mod heal_walk;
pub use heal_walk::HealWalkVersion;
mod init;
pub(crate) mod init_format;
mod list;