mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-20 11:32:19 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user