mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-12 16:16:55 +00:00
eb87bb1faf
* fix(replication): harden resync and MRF recovery * fix(replication): correct MRF validation regressions * fix(replication): address CI validation failures * fix(heal): initialize decode error in merge test
577 lines
23 KiB
Rust
577 lines
23 KiB
Rust
// 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::collections::HashSet;
|
|
use std::sync::Mutex;
|
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
use std::time::Duration;
|
|
|
|
/// Background walks skip the total timeout, so the per-read stall budget is what
|
|
/// catches a drive that stops answering. Keep it generous: a heal walk is not
|
|
/// latency-sensitive, and one slow read is not a dead drive.
|
|
const BACKGROUND_WALKDIR_STALL_TIMEOUT: Duration = Duration::from_secs(60);
|
|
|
|
/// 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>>,
|
|
decode_error: Mutex<Option<DiskError>>,
|
|
version_total: AtomicUsize,
|
|
truncated: AtomicBool,
|
|
cancel: CancellationToken,
|
|
}
|
|
|
|
impl HealWalkCollector {
|
|
fn lock_objects(&self) -> disk::error::Result<std::sync::MutexGuard<'_, Vec<HealWalkObject>>> {
|
|
self.objects.lock().map_err(|_| {
|
|
self.cancel.cancel();
|
|
DiskError::FileCorrupt
|
|
})
|
|
}
|
|
|
|
fn record_decode_error(&self, error: rustfs_filemeta::Error) {
|
|
if let Ok(mut first_error) = self.decode_error.lock()
|
|
&& first_error.is_none()
|
|
{
|
|
*first_error = Some(error.into());
|
|
}
|
|
self.cancel.cancel();
|
|
}
|
|
|
|
fn take_decode_error(&self) -> disk::error::Result<Option<DiskError>> {
|
|
self.decode_error.lock().map(|mut error| error.take()).map_err(|_| {
|
|
self.cancel.cancel();
|
|
DiskError::FileCorrupt
|
|
})
|
|
}
|
|
|
|
/// 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) => {
|
|
self.record_decode_error(err);
|
|
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 Ok(mut objects) = self.lock_objects() else {
|
|
return;
|
|
};
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// Collect versions from ALL partial entries across disks, deduplicate by
|
|
/// `(name, version_id)`, and record a single merged object. This ensures
|
|
/// that a version present on only a minority of disks (e.g. stale data on a
|
|
/// returning node that was deleted on the quorum) is surfaced for healing.
|
|
fn ingest_merged(&self, entries: &MetaCacheEntries) {
|
|
let mut name = String::new();
|
|
let mut seen = HashSet::new();
|
|
let mut versions = Vec::new();
|
|
|
|
for entry in entries.0.iter().flatten() {
|
|
if entry.is_dir() || entry.name.is_empty() {
|
|
continue;
|
|
}
|
|
if name.is_empty() {
|
|
name = entry.name.clone();
|
|
}
|
|
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 merged skipped entry with unreadable versions");
|
|
continue;
|
|
}
|
|
};
|
|
for fi in fiv.versions.iter().chain(fiv.free_versions.iter()) {
|
|
let vid = fi.version_id.filter(|u| !u.is_nil()).map(|u| u.to_string());
|
|
if seen.insert(vid.clone()) {
|
|
versions.push(HealWalkVersion {
|
|
name: entry.name.clone(),
|
|
version_id: vid,
|
|
is_delete_marker: fi.deleted,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if versions.is_empty() || name.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let added = versions.len();
|
|
let (objs_len, ver_total) = {
|
|
let Ok(mut objects) = self.lock_objects() else {
|
|
return;
|
|
};
|
|
objects.push(HealWalkObject { name, versions });
|
|
let objs_len = objects.len();
|
|
let ver_total = self.version_total.fetch_add(added, Ordering::SeqCst) + added;
|
|
(objs_len, ver_total)
|
|
};
|
|
|
|
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()),
|
|
decode_error: Mutex::new(None),
|
|
version_total: AtomicUsize::new(0),
|
|
truncated: AtomicBool::new(false),
|
|
cancel: CancellationToken::new(),
|
|
});
|
|
|
|
let agreed_collector = collector.clone();
|
|
let partial_collector = collector.clone();
|
|
|
|
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,
|
|
walkdir_stall_timeout: Some(BACKGROUND_WALKDIR_STALL_TIMEOUT),
|
|
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();
|
|
Box::pin(async move {
|
|
// Collect versions from ALL entries across disks, not just the
|
|
// winner of resolve_union. When a returning node carries a stale
|
|
// version that was deleted on the quorum, resolve_union would
|
|
// pick only one entry and lose the stale version, preventing
|
|
// its cleanup during heal.
|
|
collector.ingest_merged(&entries);
|
|
})
|
|
})),
|
|
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).
|
|
let walk_result = list_path_raw(collector.cancel.clone(), opts).await;
|
|
if let Some(err) = collector.take_decode_error()? {
|
|
return Err(err);
|
|
}
|
|
|
|
match walk_result {
|
|
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 = {
|
|
let mut objects = collector.lock_objects()?;
|
|
std::mem::take(&mut *objects)
|
|
};
|
|
let truncated = collector.truncated.load(Ordering::SeqCst);
|
|
Ok(finalize_heal_walk_page(objects, forward_to, truncated))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::set_disk::ops::object::hermetic_set_disks_support::hermetic_set_disks_isolated;
|
|
use rustfs_filemeta::{ChecksumAlgo, ErasureAlgo, FileMetaVersion, MetaObject, VersionType};
|
|
use time::OffsetDateTime;
|
|
use uuid::Uuid;
|
|
|
|
fn test_collector() -> Arc<HealWalkCollector> {
|
|
Arc::new(HealWalkCollector {
|
|
bucket: "bucket".to_string(),
|
|
batch_objects: 2,
|
|
version_budget: 2,
|
|
objects: Mutex::new(Vec::new()),
|
|
decode_error: Mutex::new(None),
|
|
version_total: AtomicUsize::new(0),
|
|
truncated: AtomicBool::new(false),
|
|
cancel: CancellationToken::new(),
|
|
})
|
|
}
|
|
|
|
fn crc_valid_semantically_corrupt_entry(name: &str) -> MetaCacheEntry {
|
|
let mut metadata = FileMeta::new();
|
|
metadata
|
|
.add_version_filemata(FileMetaVersion {
|
|
version_type: VersionType::Object,
|
|
object: Some(MetaObject {
|
|
version_id: Some(Uuid::new_v4()),
|
|
erasure_algorithm: ErasureAlgo::ReedSolomon,
|
|
erasure_m: 2,
|
|
erasure_n: 2,
|
|
erasure_block_size: 1 << 20,
|
|
bitrot_checksum_algo: ChecksumAlgo::HighwayHash,
|
|
part_numbers: vec![1, 2],
|
|
part_sizes: vec![10],
|
|
part_actual_sizes: vec![10, 20],
|
|
mod_time: Some(OffsetDateTime::now_utc()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.expect("corrupt test version should be accepted before semantic decoding");
|
|
|
|
MetaCacheEntry {
|
|
name: name.to_string(),
|
|
metadata: metadata.marshal_msg().expect("test metadata should encode with a valid CRC"),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// `ingest_merged` must surface versions from ALL entries, not just the
|
|
/// winner of `resolve_union`. This covers the stale-object-after-reconnect
|
|
/// scenario (issue #5029): a returning node carries a data version that was
|
|
/// deleted on the quorum; `resolve_union` picks one entry and loses the
|
|
/// other, but `ingest_merged` must see both.
|
|
#[test]
|
|
fn ingest_merged_surfaces_versions_from_divergent_entries() {
|
|
use rustfs_filemeta::{FileInfo, FileMeta, MetaCacheEntries, MetaCacheEntry};
|
|
use time::OffsetDateTime;
|
|
use uuid::Uuid;
|
|
|
|
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");
|
|
|
|
// Node3: stale data version (V1) — only present on one disk.
|
|
let make_entry = |name: &str, version_u128: u128, deleted: bool, ts: OffsetDateTime| -> MetaCacheEntry {
|
|
let mut fi = FileInfo::new(name, 4, 2);
|
|
fi.volume = "bucket".to_string();
|
|
fi.name = name.to_string();
|
|
fi.version_id = Some(Uuid::from_u128(version_u128));
|
|
fi.versioned = true;
|
|
fi.deleted = deleted;
|
|
fi.size = if deleted { 0 } else { 100 };
|
|
fi.mod_time = Some(ts);
|
|
fi.metadata = [("etag".to_string(), format!("etag-{version_u128:x}"))].into();
|
|
|
|
let mut meta = FileMeta::new();
|
|
meta.add_version(fi).expect("test metadata should accept version");
|
|
let encoded = meta.marshal_msg().expect("test metadata should marshal");
|
|
|
|
MetaCacheEntry {
|
|
name: name.to_string(),
|
|
metadata: encoded,
|
|
cached: Some(meta),
|
|
reusable: false,
|
|
}
|
|
};
|
|
|
|
let stale_entry = make_entry("a.txt", 0xBEEF, false, t0); // data version on returning node
|
|
let delete_entry = make_entry("a.txt", 0xCAFE, true, t1); // delete marker on quorum nodes
|
|
|
|
let collector = Arc::new(HealWalkCollector {
|
|
bucket: "bucket".to_string(),
|
|
batch_objects: 1000,
|
|
version_budget: 10_000,
|
|
objects: Mutex::new(Vec::new()),
|
|
decode_error: Mutex::new(None),
|
|
version_total: AtomicUsize::new(0),
|
|
truncated: AtomicBool::new(false),
|
|
cancel: CancellationToken::new(),
|
|
});
|
|
|
|
// Simulate the partial callback: two entries with different versions.
|
|
let entries = MetaCacheEntries(vec![Some(stale_entry), Some(delete_entry.clone()), Some(delete_entry)]);
|
|
collector.ingest_merged(&entries);
|
|
|
|
let objects = collector.lock_objects().expect("mutex should not be poisoned");
|
|
assert_eq!(objects.len(), 1, "both entries share the same object name");
|
|
let versions = &objects[0].versions;
|
|
let version_ids: std::collections::HashSet<Option<String>> = versions.iter().map(|v| v.version_id.clone()).collect();
|
|
assert!(
|
|
version_ids.contains(&Some("00000000-0000-0000-0000-00000000beef".to_string())),
|
|
"ingest_merged must surface the stale data version from the returning node: {version_ids:?}"
|
|
);
|
|
assert!(
|
|
version_ids.contains(&Some("00000000-0000-0000-0000-00000000cafe".to_string())),
|
|
"ingest_merged must also surface the delete marker from the quorum: {version_ids:?}"
|
|
);
|
|
assert_eq!(versions.len(), 2, "exactly two unique versions must be collected");
|
|
}
|
|
|
|
#[test]
|
|
fn poisoned_collector_state_cancels_the_walk() {
|
|
let collector = test_collector();
|
|
let poison_target = Arc::clone(&collector);
|
|
let _ = std::thread::spawn(move || {
|
|
let _guard = poison_target.objects.lock().expect("fresh mutex should lock");
|
|
panic!("poison heal collector");
|
|
})
|
|
.join();
|
|
|
|
let error = collector.lock_objects().expect_err("poisoned collection must fail closed");
|
|
assert_eq!(error, DiskError::FileCorrupt);
|
|
assert!(collector.cancel.is_cancelled(), "a poisoned page collector must cancel its walk");
|
|
assert!(collector.objects.lock().is_err(), "poisoned state must remain fail-closed");
|
|
}
|
|
|
|
#[test]
|
|
fn semantic_decode_failure_records_error_and_cancels_walk() {
|
|
let collector = test_collector();
|
|
let entry = crc_valid_semantically_corrupt_entry("corrupt-object");
|
|
|
|
collector.ingest(entry);
|
|
|
|
let error = collector
|
|
.take_decode_error()
|
|
.expect("decode error state should remain readable")
|
|
.expect("semantic metadata corruption must be recorded");
|
|
assert_eq!(error, DiskError::FileCorrupt);
|
|
assert!(collector.cancel.is_cancelled(), "semantic metadata corruption must cancel the disk walk");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn heal_walk_returns_crc_valid_semantic_decode_failure() {
|
|
let bucket = "bucket";
|
|
let object = "corrupt-object";
|
|
let (temp_dirs, disks, set_disks) = hermetic_set_disks_isolated(1).await;
|
|
disks[0].make_volume(bucket).await.expect("test bucket should be created");
|
|
|
|
let object_dir = temp_dirs[0].path().join(bucket).join(object);
|
|
tokio::fs::create_dir_all(&object_dir)
|
|
.await
|
|
.expect("test object directory should be created");
|
|
tokio::fs::write(
|
|
object_dir.join(crate::disk::STORAGE_FORMAT_FILE),
|
|
crc_valid_semantically_corrupt_entry(object).metadata,
|
|
)
|
|
.await
|
|
.expect("corrupt test metadata should be written");
|
|
|
|
let error = set_disks
|
|
.heal_walk_versions_page(bucket, "", None, 2, 2)
|
|
.await
|
|
.expect_err("semantic metadata corruption must fail the heal disk walk");
|
|
|
|
assert_eq!(error, DiskError::FileCorrupt);
|
|
}
|
|
}
|