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;
+63
View File
@@ -4018,6 +4018,69 @@ mod tests {
assert_eq!(merged, vec![valid]);
}
/// Build a single distinct Object version header for the union tests.
fn union_version(version_u128: u128, mod_unix: i64) -> FileMetaShallowVersion {
FileMetaShallowVersion {
header: FileMetaVersionHeader {
version_id: Some(Uuid::from_u128(version_u128)),
mod_time: Some(OffsetDateTime::from_unix_timestamp(mod_unix).expect("valid timestamp")),
signature: [0x11, 0x22, 0x33, 0x44],
version_type: VersionType::Object,
flags: 0,
ec_n: 4,
ec_m: 2,
},
meta: Vec::new(),
}
}
/// backlog#920: quorum==1 (union) must surface EVERY version present on ANY
/// per-disk stream, even when the per-disk version sets are fully DISJOINT
/// (a version living on a single disk). This is the enumeration guarantee the
/// sub-quorum disk-walk depends on.
#[test]
fn merge_union_quorum1_yields_full_union_over_disjoint_disks() {
let a = union_version(0xA, 1_705_312_300);
let b = union_version(0xB, 1_705_312_200);
let c = union_version(0xC, 1_705_312_100);
// Three disks, each holding exactly ONE distinct version (disjoint sets).
let disks = [vec![a], vec![b], vec![c]];
let merged = merge_file_meta_versions(1, true, 0, &disks);
let ids: std::collections::HashSet<Option<Uuid>> = merged.iter().map(|v| v.header.version_id).collect();
assert_eq!(merged.len(), 3, "union must retain all three disjoint versions: {merged:?}");
assert!(ids.contains(&Some(Uuid::from_u128(0xA))));
assert!(ids.contains(&Some(Uuid::from_u128(0xB))));
assert!(ids.contains(&Some(Uuid::from_u128(0xC))));
// Contrast: read-quorum (2) over the same disjoint streams surfaces NONE,
// because no version reaches two agreeing disks. This is the exact gap.
let read_quorum = merge_file_meta_versions(2, true, 0, &disks);
assert!(
read_quorum.is_empty(),
"read-quorum merge must drop every sub-quorum version, got {read_quorum:?}"
);
}
/// The equal-modTime / distinct-versionId retain-and-advance branch: two
/// versions sharing a mod_time but with different ids must BOTH survive the
/// union merge (they are not collapsed as duplicates).
#[test]
fn merge_union_quorum1_retains_equal_modtime_distinct_version_ids() {
let same_time = 1_705_312_300;
let a = union_version(0xAA, same_time);
let b = union_version(0xBB, same_time);
let disks = [vec![a], vec![b]];
let merged = merge_file_meta_versions(1, true, 0, &disks);
let ids: std::collections::HashSet<Option<Uuid>> = merged.iter().map(|v| v.header.version_id).collect();
assert_eq!(merged.len(), 2, "equal-modTime distinct-id versions must both survive: {merged:?}");
assert!(ids.contains(&Some(Uuid::from_u128(0xAA))));
assert!(ids.contains(&Some(Uuid::from_u128(0xBB))));
}
#[test]
fn meta_object_init_free_version_rejects_invalid_tier_free_version_id() {
let mut sys = HashMap::new();
+112
View File
@@ -343,6 +343,32 @@ impl MetaCacheEntries {
self.resolve_inner(params, true)
}
/// Resolve the cross-disk UNION of every version present on ANY disk slot.
///
/// This mirrors MinIO's global heal enumeration (cmd/global-heal.go
/// `healErasureSet` -> `listPathRaw` with `objQuorum = 1` feeding
/// `mergeEntries`/`mergeXLV2Versions`): at quorum 1 the merge is forced
/// strict and returns the union of all per-disk version streams, so a version
/// that survives on FEWER than read-quorum disks is still surfaced for
/// healing. Read-quorum resolution (`resolve` with `obj_quorum >= 2`) would
/// silently drop such a sub-quorum version, which is exactly the durability
/// gap this enumerator closes (backlog#920).
///
/// `bucket` is only used to tag the merged entry; `strict:false` lets the
/// non-strict header reconciliation collapse equal-but-differently-signed
/// replicas of the SAME version id while still retaining genuinely distinct
/// version ids.
pub fn resolve_union(&self, bucket: &str) -> Option<MetaCacheEntry> {
self.resolve(MetadataResolutionParams {
dir_quorum: 1,
obj_quorum: 1,
requested_versions: 0,
bucket: bucket.to_string(),
strict: false,
candidates: Vec::new(),
})
}
fn resolve_inner(&self, mut params: MetadataResolutionParams, enforce_write_quorum: bool) -> Option<MetaCacheEntry> {
if self.0.is_empty() {
debug!(
@@ -1480,6 +1506,92 @@ mod tests {
}
}
/// Build an entry holding a single object version with an explicit version id
/// and mod_time, so a set of these can model DISJOINT per-disk version sets.
fn metacache_entry_single_version(version_u128: u128, mod_time: OffsetDateTime, etag: &str) -> MetaCacheEntry {
let mut metadata = HashMap::new();
metadata.insert("etag".to_string(), etag.to_string());
let mut fi = FileInfo::new("object", 4, 2);
fi.volume = "bucket".to_string();
fi.name = "object".to_string();
fi.version_id = Some(Uuid::from_u128(version_u128));
fi.versioned = true;
fi.size = 1;
fi.mod_time = Some(mod_time);
fi.metadata = metadata;
let mut meta = FileMeta::new();
meta.add_version(fi).expect("test file metadata should accept object version");
let encoded = meta.marshal_msg().expect("test file metadata should marshal");
MetaCacheEntry {
name: "object".to_string(),
metadata: encoded,
cached: Some(meta),
reusable: false,
}
}
/// backlog#920: `resolve_union` surfaces a version present on a SINGLE slot
/// among four, while `resolve` at read-quorum (obj_quorum=2) drops it. This
/// documents the exact sub-quorum durability gap the disk-walk closes.
#[test]
fn resolve_union_surfaces_single_disk_version() {
let t0 = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
let t1 = OffsetDateTime::from_unix_timestamp(1_705_312_400).expect("valid timestamp");
// Slot 0 carries a UNIQUE version (0xBEEF) present nowhere else; the other
// three slots agree on a shared version (0xCAFE). Read-quorum sees only
// the shared one; union must see BOTH.
let unique = metacache_entry_single_version(0xBEEF, t1, "unique-etag");
let shared_a = metacache_entry_single_version(0xCAFE, t0, "shared-etag");
let shared_b = metacache_entry_single_version(0xCAFE, t0, "shared-etag");
let shared_c = metacache_entry_single_version(0xCAFE, t0, "shared-etag");
let entries = || {
MetaCacheEntries(vec![
Some(unique.clone()),
Some(shared_a.clone()),
Some(shared_b.clone()),
Some(shared_c.clone()),
])
};
let union = entries().resolve_union("bucket").expect("union must resolve an entry");
let union_meta = union.cached.expect("union entry keeps cached metadata");
let union_ids: std::collections::HashSet<Option<Uuid>> =
union_meta.versions.iter().map(|v| v.header.version_id).collect();
assert!(
union_ids.contains(&Some(Uuid::from_u128(0xBEEF))),
"union must surface the single-slot version: {union_ids:?}"
);
assert!(
union_ids.contains(&Some(Uuid::from_u128(0xCAFE))),
"union must also keep the shared version: {union_ids:?}"
);
// Read-quorum resolution (obj_quorum=2) drops the single-slot version.
let read_quorum = entries()
.resolve(MetadataResolutionParams {
obj_quorum: 2,
dir_quorum: 2,
strict: false,
..Default::default()
})
.expect("read-quorum must still resolve the shared version");
let rq_meta = read_quorum.cached.expect("read-quorum entry keeps cached metadata");
let rq_ids: std::collections::HashSet<Option<Uuid>> = rq_meta.versions.iter().map(|v| v.header.version_id).collect();
assert!(
!rq_ids.contains(&Some(Uuid::from_u128(0xBEEF))),
"read-quorum must DROP the single-slot version (the gap): {rq_ids:?}"
);
assert!(
rq_ids.contains(&Some(Uuid::from_u128(0xCAFE))),
"read-quorum must keep the shared version: {rq_ids:?}"
);
}
#[test]
fn resolve_rejects_partial_latest_and_returns_committed_previous_metadata() {
let old_mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
+27 -4
View File
@@ -579,12 +579,26 @@ impl ErasureSetHealer {
Self::effective_heal_page_object_concurrency_for_source(self.source, self.heal_opts.scan_mode);
let in_flight = Arc::new(AtomicUsize::new(0));
// backlog#920: select the per-erasure-set DISK-WALK union enumerator when
// the scan is Deep OR the request came from AutoHeal — these are the paths
// that must repair sub-quorum-but-reconstructable versions. Every other
// (Normal, non-AutoHeal) request keeps the unchanged B5 read-quorum path,
// which stays the default.
let use_disk_walk =
matches!(self.heal_opts.scan_mode, HealScanMode::Deep) || matches!(self.source, HealRequestSource::AutoHeal);
loop {
// Get one page of object versions
let (objects, next_token, is_truncated) = self
.storage
.list_objects_for_heal_page(bucket, "", continuation_token.as_deref())
.await?;
let (objects, next_token, is_truncated) = if use_disk_walk {
self.storage
.list_versions_for_heal_page_disk_walk(set_disk_id, bucket, "", continuation_token.as_deref())
.await?
} else {
self.storage
.list_objects_for_heal_page(bucket, "", continuation_token.as_deref())
.await?
};
let page_is_empty = objects.is_empty();
let checkpoint = checkpoint_manager.get_checkpoint().await;
let page_resume_index = *current_object_index;
let semaphore = Arc::new(Semaphore::new(page_concurrency_limit));
@@ -779,6 +793,15 @@ impl ErasureSetHealer {
break;
}
// Anti-loop guard: an empty page reported as truncated cannot advance
// the cursor (there is no last identity to move past), so treat it as a
// non-advancing listing and abort rather than spin forever.
if page_is_empty {
return Err(Error::other(format!(
"Erasure set heal listing for bucket {bucket} returned an empty page marked truncated; aborting to avoid an infinite loop"
)));
}
// Anti-loop guard: if the backend keeps reporting truncation but the
// last version identity did not advance, we would spin forever.
if page_last.is_some() && page_last == previous_page_last {
+212 -2
View File
@@ -182,6 +182,78 @@ pub(crate) fn decode_heal_token(token: &str) -> (Option<String>, Option<String>)
(payload.m, payload.v)
}
const DISK_WALK_TOKEN_PREFIX: &str = "dw1:";
/// Encode the disk-walk resume cursor (`next_forward` object key) into an opaque
/// continuation token: `"dw1:" + base64url_nopad(forward)`.
///
/// The `dw1:` namespace is DISJOINT from the B5 `v1:` token namespace so the two
/// enumerators can never misread each other's cursor: a `dw1:` token decodes to
/// `(None, None)` under the B5 decoder, and a `v1:` token decodes to `None` here.
pub(crate) fn encode_disk_walk_token(next_forward: &str) -> String {
format!("{DISK_WALK_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(next_forward.as_bytes()))
}
/// Decode a disk-walk continuation token back into the `next_forward` object key.
///
/// TOTAL function: an empty token, a missing `"dw1:"` prefix (including a foreign
/// B5 `v1:` token), invalid base64, or invalid UTF-8 all decode to `None` (start
/// the walk from the beginning). This makes a restart across an enumerator switch
/// idempotent rather than corrupting.
pub(crate) fn decode_disk_walk_token(token: &str) -> Option<String> {
if token.is_empty() {
return None;
}
let Some(encoded) = token.strip_prefix(DISK_WALK_TOKEN_PREFIX) else {
warn!(
target: "rustfs::heal::storage",
event = EVENT_HEAL_STORAGE_ADMIN_OP,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_STORAGE,
operation = "decode_disk_walk_token",
state = "foreign_or_missing_prefix",
"Disk-walk continuation token missing dw1 prefix; restarting walk"
);
return None;
};
let bytes = match URL_SAFE_NO_PAD.decode(encoded) {
Ok(bytes) => bytes,
Err(e) => {
warn!(
target: "rustfs::heal::storage",
event = EVENT_HEAL_STORAGE_ADMIN_OP,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_STORAGE,
operation = "decode_disk_walk_token",
state = "bad_base64",
error = %e,
"Disk-walk continuation token has invalid base64; restarting walk"
);
return None;
}
};
match String::from_utf8(bytes) {
Ok(forward) if !forward.is_empty() => Some(forward),
Ok(_) => None,
Err(e) => {
warn!(
target: "rustfs::heal::storage",
event = EVENT_HEAL_STORAGE_ADMIN_OP,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_STORAGE,
operation = "decode_disk_walk_token",
state = "bad_utf8",
error = %e,
"Disk-walk continuation token has invalid utf8; restarting walk"
);
None
}
}
}
/// A single object version to heal.
///
/// `is_delete_marker` is OBSERVABILITY-ONLY (metrics / logging / e2e
@@ -298,6 +370,26 @@ pub trait HealStorageAPI: Send + Sync {
continuation_token: Option<&str>,
) -> Result<(Vec<HealListItem>, Option<String>, bool)>;
/// List versions for healing via a per-erasure-set DISK-WALK union enumerator
/// (backlog#920). Unlike `list_objects_for_heal_page` (which reflects only the
/// READ-QUORUM metadata view via `list_object_versions`), this surfaces every
/// `(object, version)` present on ANY disk in the set identified by
/// `set_disk_id`, so sub-quorum-but-reconstructable versions are healed.
///
/// The continuation token uses the disjoint `"dw1:"` namespace. The DEFAULT
/// implementation falls back to the read-quorum listing so mock/alternate
/// storages keep compiling and behaving; `ECStoreHealStorage` overrides it
/// with the real disk walk.
async fn list_versions_for_heal_page_disk_walk(
&self,
_set_disk_id: &str,
bucket: &str,
prefix: &str,
continuation_token: Option<&str>,
) -> Result<(Vec<HealListItem>, Option<String>, bool)> {
self.list_objects_for_heal_page(bucket, prefix, continuation_token).await
}
/// Get disk for resume functionality
async fn get_disk_for_resume(&self, set_disk_id: &str) -> Result<DiskStore>;
}
@@ -1363,6 +1455,93 @@ impl HealStorageAPI for ECStoreHealStorage {
Ok((page_objects, next_token, list_info.is_truncated))
}
async fn list_versions_for_heal_page_disk_walk(
&self,
set_disk_id: &str,
bucket: &str,
prefix: &str,
continuation_token: Option<&str>,
) -> Result<(Vec<HealListItem>, Option<String>, bool)> {
// Per-page bounds for the disk-walk union enumerator. Objects are atomic
// (never split across pages), so version_budget only bounds how many
// versions accumulate before the page is cut at the next object boundary.
const BATCH_OBJECTS: usize = 1000;
const VERSION_BUDGET: usize = 10_000;
debug!(
target: "rustfs::heal::storage",
event = EVENT_HEAL_STORAGE_ADMIN_OP,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_STORAGE,
operation = "list_versions_for_heal_page_disk_walk",
set_disk_id,
bucket,
prefix,
continuation_token = ?continuation_token,
state = "started",
"Heal storage disk-walk union enumeration started"
);
let (pool_idx, set_idx) = crate::heal::utils::parse_set_disk_id(set_disk_id)?;
// Decode the dw1: cursor into the forward_to object key. Malformed/foreign
// tokens restart the walk from the beginning (decode_disk_walk_token is total).
let forward_to = decode_disk_walk_token(continuation_token.unwrap_or(""));
let (versions, next_forward, is_truncated) = self
.ecstore
.heal_walk_versions_page(pool_idx, set_idx, bucket, prefix, forward_to.as_deref(), BATCH_OBJECTS, VERSION_BUDGET)
.await
.map_err(|e| {
error!(
target: "rustfs::heal::storage",
event = EVENT_HEAL_STORAGE_ADMIN_OP,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_STORAGE,
operation = "list_versions_for_heal_page_disk_walk",
set_disk_id,
bucket,
prefix,
result = "failed",
error = %e,
"Heal storage disk-walk union enumeration failed"
);
Error::other(e)
})?;
let page_objects: Vec<HealListItem> = versions
.into_iter()
.map(|v| HealListItem {
name: v.name,
version_id: v.version_id,
is_delete_marker: v.is_delete_marker,
})
.collect();
let page_count = page_objects.len();
let next_token = if is_truncated {
next_forward.map(|fw| encode_disk_walk_token(&fw))
} else {
None
};
debug!(
target: "rustfs::heal::storage",
event = EVENT_HEAL_STORAGE_ADMIN_OP,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_STORAGE,
operation = "list_versions_for_heal_page_disk_walk",
set_disk_id,
bucket,
prefix,
version_count = page_count,
is_truncated,
state = "page_loaded",
"Heal storage disk-walk union enumeration page loaded"
);
Ok((page_objects, next_token, is_truncated))
}
async fn get_disk_for_resume(&self, set_disk_id: &str) -> Result<DiskStore> {
debug!(
target: "rustfs::heal::storage",
@@ -1411,8 +1590,8 @@ impl HealStorageAPI for ECStoreHealStorage {
mod tests {
use super::super::StorageError;
use super::{
decode_heal_token, encode_heal_token, is_transient_object_exists_error, is_transient_object_exists_message,
next_heal_listing_token,
decode_disk_walk_token, decode_heal_token, encode_disk_walk_token, encode_heal_token, is_transient_object_exists_error,
is_transient_object_exists_message, next_heal_listing_token,
};
use base64::Engine as _;
@@ -1481,6 +1660,37 @@ mod tests {
assert_eq!(decode_heal_token(&token), (None, None), "version-only marker must coerce to (None, None)");
}
#[test]
fn disk_walk_cursor_round_trip_and_foreign_token_restarts() {
// Round-trip: a real object key survives encode -> decode.
let token = encode_disk_walk_token("some/deep/object.bin");
assert!(token.starts_with("dw1:"));
assert_eq!(decode_disk_walk_token(&token), Some("some/deep/object.bin".to_string()));
// Empty / garbage / missing-prefix all restart the walk (None).
assert_eq!(decode_disk_walk_token(""), None);
assert_eq!(decode_disk_walk_token("dw1:!!!not-base64!!!"), None);
assert_eq!(decode_disk_walk_token("no-prefix-here"), None);
// CROSS-DECODER ISOLATION (both directions): a dw1 token must not be
// misread as a B5 (marker, version_marker) pair, and a v1 token must not
// be misread as a disk-walk forward cursor.
let dw = encode_disk_walk_token("obj/key");
assert_eq!(
decode_heal_token(&dw),
(None, None),
"a dw1: token must decode to (None, None) under the B5 decoder"
);
let v1 = encode_heal_token(Some("obj/key"), Some("v-123"));
assert!(v1.starts_with("v1:"));
assert_eq!(
decode_disk_walk_token(&v1),
None,
"a v1: token must decode to None under the disk-walk decoder"
);
}
#[test]
fn transient_object_exists_message_matches_lock_quorum_failures() {
assert!(is_transient_object_exists_message(
@@ -0,0 +1,568 @@
// 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.
//! backlog#920: real-disk e2e suite proving the per-erasure-set DISK-WALK UNION
//! heal enumerator surfaces (and heals) sub-quorum versions that the B5
//! read-quorum enumeration (`list_object_versions`) omits, AND that the
//! data-safety guard (decision 1) regenerates a lost xl.meta for a
//! reconstructable version instead of dangling-DELETING it.
//!
//! These drive the REAL `ECStoreHealStorage` + `ECStore` against real disks.
//! Every test is `#[serial]`; under `cargo nextest` each runs in its own process.
use http::HeaderMap;
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
use rustfs_heal::heal::storage::{
ECStoreHealStorage, HealListItem, HealObjectOptions as ObjectOptions, HealPutObjReader as PutObjReader, HealStorageAPI,
};
use serial_test::serial;
use std::{
path::{Path, PathBuf},
sync::{Arc, Once},
};
use tokio::fs;
use tokio_util::sync::CancellationToken;
use walkdir::WalkDir;
mod storage_api;
use storage_api::integration::{
BucketOperations, BucketOptions, ECStore, Endpoint, EndpointServerPools, Endpoints, MakeBucketOptions, ObjectIO as _,
PoolEndpoints, init_bucket_metadata_sys, init_local_disks,
};
/// 256 KiB + change: large enough to be stored as non-inline erasure shards, so
/// deleting the `xl.meta` file does NOT delete the data (the `part.*` shards live
/// in a sibling data-dir). This is required to exercise the "meta lost, data
/// present" rescue path — an inline object would lose its data with its xl.meta.
const NON_INLINE_TEST_DATA_SIZE: usize = 256 * 1024 + 137;
const SET_DISK_ID: &str = "pool_0_set_0";
const GRACE_ENV: &str = "RUSTFS_HEAL_DANGLING_DELETE_GRACE_SECS";
fn versioned_test_data(seed: u8) -> Vec<u8> {
(0..NON_INLINE_TEST_DATA_SIZE)
.map(|idx| ((idx + seed as usize) % 251) as u8)
.collect()
}
static INIT: Once = Once::new();
fn init_tracing() {
INIT.call_once(|| {
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init();
});
}
/// Disable the dangling-delete grace window so the destructive path is genuinely
/// LIVE in these tests: without the decision-1 guard, a recoverable version WOULD
/// be dangling-deleted here. With grace at its 1h default the delete path would be
/// masked and the test would prove nothing.
fn disable_dangling_grace() {
// Safe under nextest: each test runs in its own process and is `#[serial]`.
unsafe {
std::env::set_var(GRACE_ENV, "0");
}
}
/// Build a real N-disk single-set `ECStore` + `ECStoreHealStorage`.
async fn setup_test_env_n(n_disks: usize) -> (Vec<PathBuf>, Arc<ECStore>, Arc<ECStoreHealStorage>) {
init_tracing();
let test_base_dir = format!("/tmp/rustfs_heal_b920_test_{}", uuid::Uuid::new_v4());
let temp_dir = PathBuf::from(&test_base_dir);
if temp_dir.exists() {
fs::remove_dir_all(&temp_dir).await.ok();
}
fs::create_dir_all(&temp_dir).await.unwrap();
let disk_paths: Vec<PathBuf> = (0..n_disks).map(|i| temp_dir.join(format!("disk{}", i + 1))).collect();
for disk_path in &disk_paths {
fs::create_dir_all(disk_path).await.unwrap();
}
let mut endpoints = Vec::new();
for (i, disk_path) in disk_paths.iter().enumerate() {
let mut endpoint = Endpoint::try_from(disk_path.to_str().unwrap()).unwrap();
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(i);
endpoints.push(endpoint);
}
let pool_endpoints = PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: n_disks,
endpoints: Endpoints::from(endpoints),
cmd_line: "test".to_string(),
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
};
let endpoint_pools = EndpointServerPools::from(vec![pool_endpoints]);
init_local_disks(endpoint_pools.clone()).await.unwrap();
let server_addr: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
let ecstore = ECStore::new(server_addr, endpoint_pools, CancellationToken::new())
.await
.unwrap();
let buckets_list = ecstore
.list_bucket(&BucketOptions {
no_metadata: true,
..Default::default()
})
.await
.unwrap();
let buckets = buckets_list.into_iter().map(|v| v.name).collect();
init_bucket_metadata_sys(ecstore.clone(), buckets).await;
let heal_storage = Arc::new(ECStoreHealStorage::new(ecstore.clone()));
(disk_paths, ecstore, heal_storage)
}
async fn create_versioned_bucket(ecstore: &Arc<ECStore>, bucket: &str) {
(**ecstore)
.make_bucket(
bucket,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("failed to create versioned bucket");
}
async fn put_versioned(ecstore: &Arc<ECStore>, bucket: &str, object: &str, data: &[u8]) -> String {
let mut reader = PutObjReader::from_vec(data.to_vec());
let opts = ObjectOptions {
versioned: true,
..Default::default()
};
let info = (**ecstore)
.put_object(bucket, object, &mut reader, &opts)
.await
.expect("versioned put_object failed");
info.version_id
.map(|u| u.to_string())
.expect("versioned put must return a version id")
}
fn object_dir(disk: &Path, bucket: &str, object: &str) -> PathBuf {
disk.join(bucket).join(object)
}
/// Count `part.*` data-shard files two levels below the object dir.
fn count_part_files(obj_dir: &Path) -> usize {
if !obj_dir.exists() {
return 0;
}
WalkDir::new(obj_dir)
.min_depth(2)
.max_depth(2)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file() && e.file_name().to_str().map(|n| n.starts_with("part.")).unwrap_or(false))
.count()
}
fn xl_meta_path(obj_dir: &Path) -> PathBuf {
obj_dir.join("xl.meta")
}
/// Delete ONLY the `xl.meta` file for an object on one disk, leaving its `part.*`
/// data shards intact (models a lost-metadata-but-present-data disk).
fn remove_xl_meta_only(disk: &Path, bucket: &str, object: &str) {
let meta = xl_meta_path(&object_dir(disk, bucket, object));
assert!(meta.exists(), "xl.meta must exist before removal: {meta:?}");
std::fs::remove_file(&meta).expect("failed to remove xl.meta");
// Data dir + part files remain.
assert!(
count_part_files(&object_dir(disk, bucket, object)) >= 1,
"data shards must remain after removing only xl.meta"
);
}
fn deep_heal_opts() -> HealOpts {
HealOpts {
recreate: true,
remove: false,
scan_mode: HealScanMode::Deep,
..Default::default()
}
}
async fn read_version(ecstore: &Arc<ECStore>, bucket: &str, object: &str, version_id: &str) -> Vec<u8> {
let opts = ObjectOptions {
version_id: Some(version_id.to_string()),
..Default::default()
};
let mut reader = ecstore
.get_object_reader(bucket, object, None, HeaderMap::new(), &opts)
.await
.expect("failed to open version reader");
let mut buf = Vec::new();
tokio::io::copy(&mut reader, &mut buf)
.await
.expect("failed to read version data");
buf
}
/// Enumerate every version via the B5 read-quorum listing (walks all pages).
async fn enumerate_b5(heal_storage: &Arc<ECStoreHealStorage>, bucket: &str) -> Vec<HealListItem> {
let mut items = Vec::new();
let mut token: Option<String> = None;
loop {
let (page, next, truncated) = heal_storage
.list_objects_for_heal_page(bucket, "", token.as_deref())
.await
.expect("b5 list page failed");
items.extend(page);
if !truncated {
break;
}
token = next;
if token.is_none() {
break;
}
}
items
}
/// Enumerate every version via the disk-walk UNION enumerator (walks all pages).
async fn enumerate_disk_walk(heal_storage: &Arc<ECStoreHealStorage>, bucket: &str) -> Vec<HealListItem> {
let mut items = Vec::new();
let mut token: Option<String> = None;
loop {
let (page, next, truncated) = heal_storage
.list_versions_for_heal_page_disk_walk(SET_DISK_ID, bucket, "", token.as_deref())
.await
.expect("disk-walk list page failed");
items.extend(page);
if !truncated {
break;
}
token = next;
if token.is_none() {
break;
}
}
items
}
mod serial_tests {
use super::*;
/// The enumeration gap: a version surviving on only 1/4 disks (below the
/// read-quorum of 2) is OMITTED by the B5 `list_object_versions` enumeration
/// but INCLUDED by the disk-walk union enumerator.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn disk_walk_page_enumerates_subquorum_version_omitted_by_list_object_versions() {
let (disk_paths, ecstore, heal_storage) = setup_test_env_n(4).await;
let bucket = "b920-enum-gap";
let object = "obj.bin";
create_versioned_bucket(&ecstore, bucket).await;
let v1 = put_versioned(&ecstore, bucket, object, &versioned_test_data(1)).await;
// Wipe the object entirely on disks 1..4, leaving it on ONLY disk[0]
// (1/4 disks < read-quorum 2). effective listing_quorum for 4 drives is
// drives/2 = 2, so B5 cannot surface a 1-of-4 version.
for disk in &disk_paths[1..] {
let dir = object_dir(disk, bucket, object);
std::fs::remove_dir_all(&dir).expect("wipe object dir");
}
assert!(xl_meta_path(&object_dir(&disk_paths[0], bucket, object)).exists());
let b5 = enumerate_b5(&heal_storage, bucket).await;
assert!(
!b5.iter().any(|it| it.version_id.as_deref() == Some(v1.as_str())),
"B5 read-quorum enumeration MUST omit the 1-of-4 sub-quorum version, got {b5:?}"
);
let walk = enumerate_disk_walk(&heal_storage, bucket).await;
assert!(
walk.iter()
.any(|it| it.name == object && it.version_id.as_deref() == Some(v1.as_str())),
"disk-walk union enumeration MUST include the 1-of-4 sub-quorum version, got {walk:?}"
);
}
/// DECISION 1 PIN (highest risk): xl.meta deleted on > parity disks while the
/// data shards remain. A Deep heal (grace disabled) must REGENERATE the lost
/// xl.meta and read back byte-identical — NOT dangling-delete the version.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn union_meta_lost_data_present_is_repaired_not_destroyed() {
disable_dangling_grace();
let (disk_paths, ecstore, heal_storage) = setup_test_env_n(8).await;
let bucket = "b920-meta-lost";
let object = "obj.bin";
create_versioned_bucket(&ecstore, bucket).await;
let data_v1 = versioned_test_data(7);
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await;
// EC4+4: parity = 4. Delete ONLY xl.meta on 5 disks (> parity), leaving the
// data shards on all 8. Meta quorum (4) is now unreachable (3 metas), which
// WITHOUT the guard drives delete_if_dangling -> destruction (grace=0).
for disk in &disk_paths[0..5] {
remove_xl_meta_only(disk, bucket, object);
assert!(!xl_meta_path(&object_dir(disk, bucket, object)).exists());
}
// Heal the version through the real heal storage (Deep).
let (_result, error) = heal_storage
.heal_object(bucket, object, Some(&v1), &deep_heal_opts())
.await
.expect("heal_object call must not itself error");
assert!(
error.is_none(),
"recoverable version must heal without error (must NOT be dangling-deleted): {error:?}"
);
// xl.meta physically regenerated on the 5 meta-wiped disks.
for disk in &disk_paths[0..5] {
assert!(
xl_meta_path(&object_dir(disk, bucket, object)).exists(),
"xl.meta must be regenerated on the meta-wiped disk {disk:?}"
);
}
// The version is still present (NOT deleted) and reads back byte-identical.
assert_eq!(
read_version(&ecstore, bucket, object, &v1).await,
data_v1,
"rescued version must read back byte-identical"
);
}
/// Torn minority: a version whose DATA physically survives on FEWER than
/// data_blocks disks is genuinely unrecoverable. With grace disabled it must
/// still be dangling-deleted (the delete path is LIVE and the guard is
/// discriminating — it does not resurrect torn writes).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn deep_heal_torn_minority_is_dangling_deleted_with_grace_zero() {
disable_dangling_grace();
let (disk_paths, ecstore, heal_storage) = setup_test_env_n(4).await;
let bucket = "b920-torn";
let object = "obj.bin";
create_versioned_bucket(&ecstore, bucket).await;
let v1 = put_versioned(&ecstore, bucket, object, &versioned_test_data(3)).await;
// EC2+2 (4 drives, parity 2, data_blocks 2). Wipe the object ENTIRELY
// (meta + data) on 3 of 4 disks, leaving it on only 1 (< data_blocks 2):
// genuinely unrecoverable => dangling delete after (zero) grace.
for disk in &disk_paths[1..] {
std::fs::remove_dir_all(object_dir(disk, bucket, object)).expect("wipe object dir");
}
// The surviving minority copy IS present before heal (so the delete below
// is a real destructive action, not a no-op).
assert_eq!(count_part_files(&object_dir(&disk_paths[0], bucket, object)), 1);
let (_result, error) = heal_storage
.heal_object(bucket, object, Some(&v1), &deep_heal_opts())
.await
.expect("heal_object call must not itself error");
// A dangling delete reports the version as gone (FileVersionNotFound),
// proving the destructive path fired for a genuinely torn write.
assert!(error.is_some(), "a torn (< data_blocks) version must NOT be silently treated as healed");
// Destructive-path PROOF: the stale minority copy on disk0 was purged by
// the dangling delete (the guard correctly did NOT rescue a torn write).
assert_eq!(
count_part_files(&object_dir(&disk_paths[0], bucket, object)),
0,
"torn-write dangling delete must purge the surviving minority shard on disk0"
);
// And the wiped disks are not resurrected.
for disk in &disk_paths[1..] {
assert_eq!(
count_part_files(&object_dir(disk, bucket, object)),
0,
"torn-write heal must not resurrect data on the wiped disk {disk:?}"
);
}
}
/// A version present on exactly data_blocks=4 disks (< listing_quorum) yet
/// EC-reconstructable: after Deep heal the part.* + xl.meta are physically
/// restored on the 4 wiped disks and the data reads back byte-identical. B5
/// omits it; the disk-walk enumerates it.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn deep_heal_restores_subquorum_but_reconstructable_version_wider_set() {
disable_dangling_grace();
let (disk_paths, ecstore, heal_storage) = setup_test_env_n(8).await;
let bucket = "b920-reconstruct";
let object = "obj.bin";
create_versioned_bucket(&ecstore, bucket).await;
let data_v1 = versioned_test_data(9);
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await;
// EC4+4: wipe the object ENTIRELY on 4 disks, leaving full copies on the
// other 4 (== data_blocks). Meta quorum (4) still holds, so this heals via
// the normal reconstruction path once enumerated by the disk walk.
for disk in &disk_paths[0..4] {
std::fs::remove_dir_all(object_dir(disk, bucket, object)).expect("wipe object dir");
assert_eq!(count_part_files(&object_dir(disk, bucket, object)), 0);
}
// The disk-walk enumerates it (union view).
let walk = enumerate_disk_walk(&heal_storage, bucket).await;
assert!(
walk.iter().any(|it| it.version_id.as_deref() == Some(v1.as_str())),
"disk-walk must enumerate the reconstructable sub-quorum version"
);
let (_result, error) = heal_storage
.heal_object(bucket, object, Some(&v1), &deep_heal_opts())
.await
.expect("heal_object call must not itself error");
assert!(error.is_none(), "reconstructable version must heal cleanly: {error:?}");
// part.* + xl.meta physically restored on the 4 wiped disks.
for disk in &disk_paths[0..4] {
assert!(
xl_meta_path(&object_dir(disk, bucket, object)).exists(),
"xl.meta restored on wiped disk {disk:?}"
);
assert_eq!(
count_part_files(&object_dir(disk, bucket, object)),
1,
"data shard restored on wiped disk {disk:?}"
);
}
assert_eq!(
read_version(&ecstore, bucket, object, &v1).await,
data_v1,
"reconstructed version must read back byte-identical"
);
}
/// The bounded disk-walk paginates across a multi-object bucket with
/// batch_objects=2, healing every version exactly once with a monotonic,
/// `dw1:`-tagged resume cursor and no anti-loop trip on the de-overlapped tail.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn disk_walk_multipage_resume_heals_each_version_once() {
let (_disk_paths, ecstore, _heal_storage) = setup_test_env_n(4).await;
let bucket = "b920-multipage";
create_versioned_bucket(&ecstore, bucket).await;
// 5 objects, 1 version each.
let mut expected: Vec<(String, String)> = Vec::new();
for i in 0..5u8 {
let object = format!("obj-{i}.bin");
let v = put_versioned(&ecstore, bucket, &object, &versioned_test_data(i + 20)).await;
expected.push((object, v));
}
// Page the raw ecstore disk-walk directly with an explicit small bound so
// pagination is actually exercised (the storage-API wrapper uses a large
// batch that would not split this fixture).
let mut seen: Vec<(String, Option<String>)> = Vec::new();
let mut forward: Option<String> = None;
let mut pages = 0usize;
loop {
let (versions, next_forward, truncated) = ecstore
.heal_walk_versions_page(0, 0, bucket, "", forward.as_deref(), 2, 100_000)
.await
.expect("heal_walk_versions_page failed");
pages += 1;
for v in &versions {
seen.push((v.name.clone(), v.version_id.clone()));
}
if !truncated {
break;
}
let nf = next_forward.expect("truncated page must carry a next_forward");
// Cursor must strictly advance (monotonic) to avoid loops.
if let Some(prev) = &forward {
assert!(&nf > prev, "resume cursor must advance monotonically: {prev} -> {nf}");
}
forward = Some(nf);
assert!(pages < 20, "pagination must terminate");
}
assert!(pages >= 2, "batch_objects=2 over 5 objects must span multiple pages, pages={pages}");
// Every version enumerated EXACTLY once (no drops, no duplicates from the
// inclusive-forward de-overlap).
assert_eq!(seen.len(), expected.len(), "each version must appear exactly once: {seen:?}");
for (object, v) in &expected {
let hits = seen
.iter()
.filter(|(n, vid)| n == object && vid.as_deref() == Some(v.as_str()))
.count();
assert_eq!(hits, 1, "version {object}/{v} must be enumerated exactly once, got {hits}");
}
}
/// A disk that lost this object (its shard is gone) during the walk must not
/// cause a dangling delete: the union still enumerates the version from the
/// remaining disks (min_disks=1), and the still-quorum-present object heals
/// cleanly and reads back byte-identical (grace disabled).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn offline_disk_during_walk_does_not_dangling_delete() {
disable_dangling_grace();
let (disk_paths, ecstore, heal_storage) = setup_test_env_n(4).await;
let bucket = "b920-offline";
let object = "obj.bin";
create_versioned_bucket(&ecstore, bucket).await;
let data_v1 = versioned_test_data(11);
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await;
// Drop the object entirely on ONE disk (its shard + meta gone), leaving it
// on 3/4 (>= data_blocks 2). The union must still enumerate it.
std::fs::remove_dir_all(object_dir(&disk_paths[3], bucket, object)).expect("wipe object on disk3");
let walk = enumerate_disk_walk(&heal_storage, bucket).await;
assert!(
walk.iter().any(|it| it.version_id.as_deref() == Some(v1.as_str())),
"a disk missing this object must not drop the version from the union, got {walk:?}"
);
// Healing must NOT dangling-delete: the object is present on 3/4 (quorum).
// (Normal scan: the disk-walk enumerator gates on Deep OR AutoHeal, but the
// per-version repair itself is scan-mode agnostic — this pins that a
// missing-shard disk is reconstructed, not dangling-deleted.)
let normal_opts = HealOpts {
recreate: true,
remove: false,
..Default::default()
};
let (_result, error) = heal_storage
.heal_object(bucket, object, Some(&v1), &normal_opts)
.await
.expect("heal_object call must not itself error");
assert!(error.is_none(), "quorum-present object must not be destroyed: {error:?}");
assert!(
xl_meta_path(&object_dir(&disk_paths[3], bucket, object)).exists(),
"the missing shard's xl.meta must be restored on disk3"
);
assert_eq!(
read_version(&ecstore, bucket, object, &v1).await,
data_v1,
"version must remain byte-identical after heal"
);
}
}