mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-21 11:56:38 +00:00
f05a69d51b
* test(utils): add rustfs-test-utils crate, absorb heal/iam ECStore bootstrap
backlog#1153 infra-1. The ~50-line "build a real temp-disk ECStore"
bootstrap was copy-pasted (and drifting) across the heal and iam
integration tests. This adds crates/test-utils (rustfs-test-utils, a
dev-dependency-only crate) owning that bootstrap and converts the four
copies into thin wrappers:
- TestECStoreEnvBuilder: disk_count (default 4), prefix (uuid-suffixed
/tmp dir), base_dir (caller-owned dir, e.g. tempfile::TempDir),
init_bucket_metadata (default true; the iam bootstrap test opts out
to preserve its historical semantics). TestECStoreEnv exposes
temp_root/disk_paths/ecstore plus a versioned-bucket helper, and
init_tracing() replaces the per-file Once blocks.
- All rustfs_ecstore imports stay behind src/ecstore_test_compat.rs,
the sanctioned test-compat boundary pattern (mirrors
crates/iam/tests/ecstore_test_compat).
- heal: heal_integration_test / heal_b5_versioned_regression_test /
heal_b920_subquorum_union_test drop their setup_test_env{,_n} copies
for heal_env{,_n} wrappers; the tests/storage_api.rs integration
surface shrinks to what test bodies still touch.
- iam: iam_bootstrap_no_lock_test drops build_local_ecstore; its
ecstore_test_compat fixture shrinks to SetupType +
update_erasure_type.
rg 'async fn setup_test_env' crates/heal crates/iam now returns 0.
Scanner's lifecycle tests are deliberately NOT absorbed (gated on
ilm-1; 14 of 15 are #[ignore]d today). Net -230 lines.
* fix(heal): drop tokio::fs import orphaned by the b920 bootstrap move
* fix(heal): drop tokio::fs import orphaned by the b5 bootstrap move
549 lines
23 KiB
Rust
549 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.
|
|
|
|
//! 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,
|
|
};
|
|
use walkdir::WalkDir;
|
|
|
|
mod storage_api;
|
|
|
|
use storage_api::integration::{BucketOperations, ECStore, MakeBucketOptions, ObjectIO as _};
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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` via the
|
|
/// shared rustfs-test-utils environment (backlog#1153 infra-1).
|
|
async fn heal_env_n(n_disks: usize) -> (Vec<PathBuf>, Arc<ECStore>, Arc<ECStoreHealStorage>) {
|
|
let env = rustfs_test_utils::TestECStoreEnv::builder()
|
|
.prefix("rustfs_heal_b920_test")
|
|
.disk_count(n_disks)
|
|
.build()
|
|
.await;
|
|
let heal_storage = Arc::new(ECStoreHealStorage::new(env.ecstore.clone()));
|
|
(env.disk_paths, env.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) = heal_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) = heal_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) = heal_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) = heal_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) = heal_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) = heal_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"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[serial]
|
|
async fn deep_heal_keeps_present_ec2_plus_2_shards_healthy() {
|
|
disable_dangling_grace();
|
|
let (disk_paths, ecstore, heal_storage) = heal_env_n(4).await;
|
|
let bucket = "b1044-deep-verify";
|
|
let object = "obj.bin";
|
|
create_versioned_bucket(&ecstore, bucket).await;
|
|
|
|
let data_v1 = versioned_test_data(12);
|
|
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await;
|
|
|
|
std::fs::remove_dir_all(object_dir(&disk_paths[3], bucket, object)).expect("wipe object on disk3");
|
|
for disk in &disk_paths[..3] {
|
|
assert_eq!(count_part_files(&object_dir(disk, bucket, object)), 1, "intact shard must remain present");
|
|
}
|
|
|
|
let (_result, error) = heal_storage
|
|
.heal_object(bucket, object, Some(&v1), &deep_heal_opts())
|
|
.await
|
|
.expect("deep heal_object call must not itself error");
|
|
assert!(error.is_none(), "Deep heal must retain the three intact EC2+2 shards: {error:?}");
|
|
|
|
assert!(
|
|
xl_meta_path(&object_dir(&disk_paths[3], bucket, object)).exists(),
|
|
"Deep heal must restore xl.meta on the missing disk"
|
|
);
|
|
assert_eq!(
|
|
count_part_files(&object_dir(&disk_paths[3], bucket, object)),
|
|
1,
|
|
"Deep heal must restore the missing shard"
|
|
);
|
|
assert_eq!(
|
|
read_version(&ecstore, bucket, object, &v1).await,
|
|
data_v1,
|
|
"Deep-healed EC2+2 object must remain byte-identical"
|
|
);
|
|
}
|
|
}
|