mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 08:18:18 +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
617 lines
27 KiB
Rust
617 lines
27 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.
|
|
|
|
//! B5-2 (rustfs/backlog#919): real-disk-wipe e2e regression suite proving the
|
|
//! B5-1 version-aware heal enumeration physically repairs OLD non-latest
|
|
//! versions and DELETE-MARKER-latest objects.
|
|
//!
|
|
//! These drive the REAL `ECStoreHealStorage` (not a mock) against a real 4-disk
|
|
//! `ECStore`, mirroring `heal_integration_test.rs`. Every test is `#[serial]`
|
|
//! and re-runs the full `heal_env` init (which sets the process-global
|
|
//! bucket-metadata-sys OnceCell) — under `cargo nextest` each test runs
|
|
//! in its own process so the OnceCell never collides.
|
|
|
|
use http::HeaderMap;
|
|
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
|
|
use rustfs_heal::heal::{
|
|
manager::{HealConfig, HealManager},
|
|
storage::{
|
|
ECStoreHealStorage, HealListItem, HealObjectOptions as ObjectOptions, HealPutObjReader as PutObjReader, HealStorageAPI,
|
|
},
|
|
task::{HealOptions, HealPriority, HealRequest, HealTaskStatus, HealType},
|
|
};
|
|
use serial_test::serial;
|
|
use std::{
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
time::Duration,
|
|
};
|
|
use tracing::info;
|
|
use walkdir::WalkDir;
|
|
|
|
mod storage_api;
|
|
|
|
use storage_api::integration::{BucketOperations, ECStore, MakeBucketOptions, ObjectIO as _, ObjectOperations as _};
|
|
|
|
/// 256 KiB + change: large enough to be stored as non-inline erasure shards
|
|
/// (so each data version materializes as an on-disk `part.*` file we can assert
|
|
/// was physically restored on the wiped disk).
|
|
const NON_INLINE_TEST_DATA_SIZE: usize = 256 * 1024 + 137;
|
|
|
|
const SET_DISK_ID: &str = "pool_0_set_0";
|
|
|
|
fn versioned_test_data(seed: u8) -> Vec<u8> {
|
|
(0..NON_INLINE_TEST_DATA_SIZE)
|
|
.map(|idx| ((idx + seed as usize) % 251) as u8)
|
|
.collect()
|
|
}
|
|
|
|
/// Build a real 4-disk `ECStore` + `ECStoreHealStorage` via the shared
|
|
/// rustfs-test-utils environment (backlog#1153 infra-1). Mirrors
|
|
/// `heal_integration_test::heal_env`.
|
|
async fn heal_env() -> (Vec<PathBuf>, Arc<ECStore>, Arc<ECStoreHealStorage>) {
|
|
let env = rustfs_test_utils::TestECStoreEnv::builder()
|
|
.prefix("rustfs_heal_b5_test")
|
|
.build()
|
|
.await;
|
|
let heal_storage = Arc::new(ECStoreHealStorage::new(env.ecstore.clone()));
|
|
(env.disk_paths, env.ecstore, heal_storage)
|
|
}
|
|
|
|
/// Create a bucket with S3 versioning ENABLED at creation time. Without this the
|
|
/// second PUT overwrites in place and DELETE removes the object outright — no
|
|
/// old versions and no delete-marker-latest would ever exist (empty-pass trap).
|
|
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 create_unversioned_bucket(ecstore: &Arc<ECStore>, bucket: &str) {
|
|
(**ecstore)
|
|
.make_bucket(bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("failed to create unversioned bucket");
|
|
}
|
|
|
|
/// PUT a new version (versioned:true forces a fresh version id per write) and
|
|
/// return the created version id as a String.
|
|
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")
|
|
}
|
|
|
|
async fn put_unversioned(ecstore: &Arc<ECStore>, bucket: &str, object: &str, data: &[u8]) {
|
|
let mut reader = PutObjReader::from_vec(data.to_vec());
|
|
(**ecstore)
|
|
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
|
.await
|
|
.expect("unversioned put_object failed");
|
|
}
|
|
|
|
/// Create a delete-marker as the latest version (versioned:true, no version_id)
|
|
/// and return the delete-marker's version id.
|
|
async fn put_delete_marker(ecstore: &Arc<ECStore>, bucket: &str, object: &str) -> String {
|
|
let opts = ObjectOptions {
|
|
versioned: true,
|
|
..Default::default()
|
|
};
|
|
let info = (**ecstore)
|
|
.delete_object(bucket, object, opts)
|
|
.await
|
|
.expect("versioned delete (delete-marker) failed");
|
|
assert!(info.delete_marker, "delete on a versioned bucket must yield a delete marker");
|
|
info.version_id
|
|
.map(|u| u.to_string())
|
|
.expect("delete marker must carry a version id")
|
|
}
|
|
|
|
/// On-disk object directory for a given disk: `<disk>/<bucket>/<object>/`.
|
|
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
|
|
/// (`<object>/<data-uuid>/part.N`). One data dir per non-delete-marker version.
|
|
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")
|
|
}
|
|
|
|
fn recreate_heal_opts() -> HealOpts {
|
|
// Mirrors the proven-working object heal opts in
|
|
// `heal_integration_test::test_heal_format_with_data`.
|
|
HealOpts {
|
|
recreate: true,
|
|
remove: false,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Enumerate every version via the REAL B5-1 paged listing, walking all pages.
|
|
async fn enumerate_all_versions(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("list_objects_for_heal_page failed");
|
|
items.extend(page);
|
|
if !truncated {
|
|
break;
|
|
}
|
|
token = next;
|
|
if token.is_none() {
|
|
break;
|
|
}
|
|
}
|
|
items
|
|
}
|
|
|
|
/// Heal every enumerated version through the REAL `ECStoreHealStorage`, mirroring
|
|
/// the production per-version loop. Returns (healed_ok, failed).
|
|
async fn heal_all_versions(heal_storage: &Arc<ECStoreHealStorage>, bucket: &str) -> (usize, usize) {
|
|
let items = enumerate_all_versions(heal_storage, bucket).await;
|
|
let opts = recreate_heal_opts();
|
|
let mut healed = 0usize;
|
|
let mut failed = 0usize;
|
|
for item in items {
|
|
let (result, error) = heal_storage
|
|
.heal_object(bucket, &item.name, item.version_id.as_deref(), &opts)
|
|
.await
|
|
.expect("heal_object call itself must not error out");
|
|
if error.is_some() {
|
|
failed += 1;
|
|
info!(
|
|
"heal_object reported error for {}/{} v={:?} dm={}: {:?}",
|
|
bucket, item.name, item.version_id, item.is_delete_marker, error
|
|
);
|
|
} else {
|
|
healed += 1;
|
|
}
|
|
let _ = result;
|
|
}
|
|
(healed, failed)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
mod serial_tests {
|
|
use super::*;
|
|
|
|
/// Directly exercises `ECStoreHealStorage::list_objects_for_heal_page` on a
|
|
/// real versioned fixture: two data versions + a delete-marker-latest. Proves
|
|
/// enumeration returns one `HealListItem` per version, flags the delete marker
|
|
/// via `is_delete_marker`, and carries the correct version ids.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[serial]
|
|
async fn test_enumeration_includes_all_versions_and_delete_marker_real_fixture() {
|
|
let (_disk_paths, ecstore, heal_storage) = heal_env().await;
|
|
let bucket = "b5-enum-versions";
|
|
let object = "obj.bin";
|
|
create_versioned_bucket(&ecstore, bucket).await;
|
|
|
|
let v1 = put_versioned(&ecstore, bucket, object, &versioned_test_data(1)).await;
|
|
let v2 = put_versioned(&ecstore, bucket, object, &versioned_test_data(2)).await;
|
|
let dm = put_delete_marker(&ecstore, bucket, object).await;
|
|
assert_ne!(v1, v2);
|
|
assert_ne!(v2, dm);
|
|
|
|
let items = enumerate_all_versions(&heal_storage, bucket).await;
|
|
|
|
// FIXTURE-NON-EMPTY GUARD: exactly the three versions we created.
|
|
assert_eq!(items.len(), 3, "must enumerate every version + the delete marker, got {items:?}");
|
|
assert!(items.iter().all(|it| it.name == object), "all entries belong to the same object");
|
|
|
|
let dm_items: Vec<&HealListItem> = items.iter().filter(|it| it.is_delete_marker).collect();
|
|
assert_eq!(dm_items.len(), 1, "exactly one entry must be flagged as a delete marker");
|
|
assert_eq!(
|
|
dm_items[0].version_id.as_deref(),
|
|
Some(dm.as_str()),
|
|
"the delete-marker entry must carry the delete marker's version id"
|
|
);
|
|
|
|
let ids: std::collections::HashSet<Option<String>> = items.iter().map(|it| it.version_id.clone()).collect();
|
|
assert!(ids.contains(&Some(v1.clone())), "old version v1 must be enumerated");
|
|
assert!(ids.contains(&Some(v2.clone())), "version v2 must be enumerated");
|
|
assert!(ids.contains(&Some(dm.clone())), "delete marker version must be enumerated");
|
|
// Every version id is a real (non-nil) id, so none normalized to None.
|
|
assert!(items.iter().all(|it| it.version_id.is_some()), "versioned entries must keep their ids");
|
|
}
|
|
|
|
/// Physical repair of an OLD non-latest version after wiping one disk.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[serial]
|
|
async fn test_heal_old_nonlatest_version_after_disk_wipe() {
|
|
let (disk_paths, ecstore, heal_storage) = heal_env().await;
|
|
let bucket = "b5-old-version-heal";
|
|
let object = "obj.bin";
|
|
create_versioned_bucket(&ecstore, bucket).await;
|
|
|
|
let data_v1 = versioned_test_data(10);
|
|
let data_v2 = versioned_test_data(20);
|
|
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await; // OLD, non-latest
|
|
let v2 = put_versioned(&ecstore, bucket, object, &data_v2).await; // latest
|
|
|
|
// ── Pre-wipe: prove the fixture actually has 2 versions on disk[0] ──
|
|
let obj_dir0 = object_dir(&disk_paths[0], bucket, object);
|
|
assert!(xl_meta_path(&obj_dir0).exists(), "xl.meta must exist before wipe");
|
|
assert_eq!(
|
|
count_part_files(&obj_dir0),
|
|
2,
|
|
"two data versions must each have an on-disk shard before wipe"
|
|
);
|
|
let pre_items = enumerate_all_versions(&heal_storage, bucket).await;
|
|
assert_eq!(pre_items.len(), 2, "fixture must expose both versions");
|
|
|
|
// ── Wipe disk[0]'s object dir (xl.meta + both data dirs) ──
|
|
std::fs::remove_dir_all(&obj_dir0).expect("failed to wipe object dir on disk[0]");
|
|
assert!(!obj_dir0.exists(), "object dir must be gone after wipe");
|
|
assert_eq!(count_part_files(&obj_dir0), 0, "no shards on disk[0] after wipe");
|
|
|
|
// ── Heal every enumerated version through the real heal storage ──
|
|
let (healed, failed) = heal_all_versions(&heal_storage, bucket).await;
|
|
assert_eq!(failed, 0, "no version may be recorded as failed");
|
|
assert!(healed >= 2, "both versions must be healed, healed={healed}");
|
|
|
|
// ── Post-heal: disk[0] physically restored, incl. the OLD version ──
|
|
assert!(xl_meta_path(&obj_dir0).exists(), "xl.meta must be restored on the wiped disk");
|
|
assert_eq!(
|
|
count_part_files(&obj_dir0),
|
|
2,
|
|
"both versions' data shards must be physically restored on disk[0]"
|
|
);
|
|
|
|
// Old non-latest version data must be intact and readable end-to-end.
|
|
assert_eq!(read_version(&ecstore, bucket, object, &v1).await, data_v1, "old version data corrupted");
|
|
assert_eq!(
|
|
read_version(&ecstore, bucket, object, &v2).await,
|
|
data_v2,
|
|
"latest version data corrupted"
|
|
);
|
|
}
|
|
|
|
/// Delete-marker-latest objects must be enumerated AND healed after a wipe.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[serial]
|
|
async fn test_heal_delete_marker_latest_enumerated_and_healed() {
|
|
let (disk_paths, ecstore, heal_storage) = heal_env().await;
|
|
let bucket = "b5-dm-latest-heal";
|
|
let object = "obj.bin";
|
|
create_versioned_bucket(&ecstore, bucket).await;
|
|
|
|
let data_v1 = versioned_test_data(30);
|
|
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await; // data version
|
|
let dm = put_delete_marker(&ecstore, bucket, object).await; // delete-marker latest
|
|
|
|
// ── Pre-wipe fixture proof: one data shard + a delete-marker latest ──
|
|
let obj_dir0 = object_dir(&disk_paths[0], bucket, object);
|
|
assert!(xl_meta_path(&obj_dir0).exists(), "xl.meta must exist before wipe");
|
|
assert_eq!(count_part_files(&obj_dir0), 1, "one data version => one shard before wipe");
|
|
let pre_items = enumerate_all_versions(&heal_storage, bucket).await;
|
|
assert_eq!(pre_items.len(), 2, "must enumerate the data version and the delete marker");
|
|
assert!(
|
|
pre_items
|
|
.iter()
|
|
.any(|it| it.is_delete_marker && it.version_id.as_deref() == Some(dm.as_str())),
|
|
"the delete-marker-latest must be enumerated as a heal unit"
|
|
);
|
|
// The latest, per get_object_info, is a delete marker (object reads as deleted).
|
|
let latest = ecstore.get_object_info(bucket, object, &ObjectOptions::default()).await;
|
|
assert!(
|
|
matches!(&latest, Ok(info) if info.delete_marker) || latest.is_err(),
|
|
"latest must resolve to a delete marker before heal"
|
|
);
|
|
|
|
// ── Wipe disk[0]'s object dir ──
|
|
std::fs::remove_dir_all(&obj_dir0).expect("failed to wipe object dir on disk[0]");
|
|
assert_eq!(count_part_files(&obj_dir0), 0, "no shards on disk[0] after wipe");
|
|
|
|
// ── Heal all enumerated versions (data version + delete marker) ──
|
|
let (healed, failed) = heal_all_versions(&heal_storage, bucket).await;
|
|
assert_eq!(failed, 0, "delete marker + data version must not be recorded as failed");
|
|
assert!(healed >= 2, "both the delete marker and the data version must heal, healed={healed}");
|
|
|
|
// ── Post-heal: xl.meta (with the tombstone) + the data shard restored ──
|
|
assert!(
|
|
xl_meta_path(&obj_dir0).exists(),
|
|
"xl.meta (carrying the delete-marker tombstone) must be restored on disk[0]"
|
|
);
|
|
assert_eq!(
|
|
count_part_files(&obj_dir0),
|
|
1,
|
|
"the underlying data version's shard must be physically restored on disk[0]"
|
|
);
|
|
|
|
// The old data version is still readable by id; the latest is still a DM.
|
|
assert_eq!(
|
|
read_version(&ecstore, bucket, object, &v1).await,
|
|
data_v1,
|
|
"healed data version corrupted"
|
|
);
|
|
let latest_after = ecstore.get_object_info(bucket, object, &ObjectOptions::default()).await;
|
|
assert!(
|
|
matches!(&latest_after, Ok(info) if info.delete_marker) || latest_after.is_err(),
|
|
"latest must remain a delete marker after heal"
|
|
);
|
|
}
|
|
|
|
/// Unversioned objects normalize to `version_id == None` and enumerate once
|
|
/// each (never a phantom `Some("null")`).
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[serial]
|
|
async fn test_version_id_normalization_null_and_unversioned_real_fixture() {
|
|
let (_disk_paths, ecstore, heal_storage) = heal_env().await;
|
|
let bucket = "b5-unversioned-normalize";
|
|
create_unversioned_bucket(&ecstore, bucket).await;
|
|
|
|
put_unversioned(&ecstore, bucket, "a.bin", &versioned_test_data(1)).await;
|
|
put_unversioned(&ecstore, bucket, "b.bin", &versioned_test_data(2)).await;
|
|
// Overwrite b.bin: on an unversioned bucket this replaces in place, so
|
|
// there is still exactly one enumerable unit for it.
|
|
put_unversioned(&ecstore, bucket, "b.bin", &versioned_test_data(3)).await;
|
|
|
|
let items = enumerate_all_versions(&heal_storage, bucket).await;
|
|
assert_eq!(items.len(), 2, "unversioned bucket => exactly one heal unit per object, got {items:?}");
|
|
assert!(
|
|
items.iter().all(|it| it.version_id.is_none()),
|
|
"unversioned objects must normalize to version_id == None, never Some(\"null\"): {items:?}"
|
|
);
|
|
assert!(items.iter().all(|it| !it.is_delete_marker), "no delete markers expected");
|
|
let names: std::collections::HashSet<&str> = items.iter().map(|it| it.name.as_str()).collect();
|
|
assert!(names.contains("a.bin") && names.contains("b.bin"), "both objects enumerated once");
|
|
|
|
// NOTE: A literal MinIO-interop "null" *string* version id is only minted
|
|
// by the S3 request layer (versioning-suspended writes); it cannot be
|
|
// constructed through the internal ECStore put path used here (suspended/
|
|
// unversioned writes store no version id at all -> None). The nil-UUID ->
|
|
// None normalization itself is unit-covered in B5-1
|
|
// (crates/heal storage list_objects_for_heal_page maps
|
|
// `version_id.filter(|u| !u.is_nil())`). This test covers the real
|
|
// unversioned-object => None path end-to-end.
|
|
}
|
|
|
|
/// Unversioned bucket, full heal path via `HealManager` (recursive bucket
|
|
/// heal), after wiping one disk. Every object is healed exactly once and
|
|
/// remains readable; nothing is dropped or double-processed.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[serial]
|
|
async fn test_heal_unversioned_bucket_e2e() {
|
|
let (disk_paths, ecstore, heal_storage) = heal_env().await;
|
|
let bucket = "b5-unversioned-e2e";
|
|
create_unversioned_bucket(&ecstore, bucket).await;
|
|
|
|
let objects = ["o1.bin", "o2.bin", "o3.bin"];
|
|
let mut datas = Vec::new();
|
|
for (i, obj) in objects.iter().enumerate() {
|
|
let data = versioned_test_data(i as u8 + 40);
|
|
put_unversioned(&ecstore, bucket, obj, &data).await;
|
|
datas.push(data);
|
|
}
|
|
|
|
// Wipe every object dir on disk[0].
|
|
for obj in &objects {
|
|
let dir = object_dir(&disk_paths[0], bucket, obj);
|
|
std::fs::remove_dir_all(&dir).expect("failed to wipe object dir");
|
|
assert_eq!(count_part_files(&dir), 0);
|
|
}
|
|
|
|
// Enumeration returns exactly one unit per object (no duplicates).
|
|
let items = enumerate_all_versions(&heal_storage, bucket).await;
|
|
assert_eq!(items.len(), objects.len(), "one heal unit per object");
|
|
assert!(items.iter().all(|it| it.version_id.is_none()));
|
|
|
|
// Drive the real recursive bucket heal through the HealManager task loop.
|
|
let cfg = HealConfig {
|
|
heal_interval: Duration::from_millis(1),
|
|
..Default::default()
|
|
};
|
|
let heal_manager = HealManager::new(heal_storage.clone(), Some(cfg));
|
|
heal_manager.start().await.unwrap();
|
|
let request = HealRequest::new(
|
|
HealType::Bucket {
|
|
bucket: bucket.to_string(),
|
|
},
|
|
HealOptions {
|
|
recursive: true,
|
|
recreate_missing: true,
|
|
scan_mode: HealScanMode::Normal,
|
|
timeout: Some(Duration::from_secs(300)),
|
|
..Default::default()
|
|
},
|
|
HealPriority::Normal,
|
|
);
|
|
let task_id = request.id.clone();
|
|
let admission = heal_manager
|
|
.submit_heal_request(request)
|
|
.await
|
|
.expect("failed to submit unversioned bucket heal");
|
|
assert!(admission.is_admitted());
|
|
|
|
wait_for_task(&heal_manager, &task_id, Duration::from_secs(60)).await;
|
|
|
|
// Every object is physically restored on disk[0] and reads back intact.
|
|
for (obj, data) in objects.iter().zip(datas.iter()) {
|
|
let dir = object_dir(&disk_paths[0], bucket, obj);
|
|
assert!(xl_meta_path(&dir).exists(), "{obj}: xl.meta not restored on disk[0]");
|
|
assert_eq!(count_part_files(&dir), 1, "{obj}: data shard not restored on disk[0]");
|
|
let mut reader = ecstore
|
|
.get_object_reader(bucket, obj, None, HeaderMap::new(), &ObjectOptions::default())
|
|
.await
|
|
.expect("healed object must be readable");
|
|
let mut buf = Vec::new();
|
|
tokio::io::copy(&mut reader, &mut buf).await.unwrap();
|
|
assert_eq!(&buf, data, "{obj}: healed data mismatch");
|
|
}
|
|
}
|
|
|
|
/// End-to-end drive of the REAL resume/checkpoint-backed erasure-set healer
|
|
/// (`ErasureSet` heal type -> `ErasureSetHealer::heal_bucket_with_resume`)
|
|
/// against real disks with a multi-version fixture across a wiped disk.
|
|
///
|
|
/// SCOPE NOTE: literally crossing the 1000-key `list_object_versions` page
|
|
/// boundary at e2e scale (1000+ real versions) is impractical for a unit-speed
|
|
/// test, so that exact boundary + mid-page cancel/resume behavior is covered
|
|
/// by the B5-1 in-crate loop tests
|
|
/// (`erasure_healer::resume_loop_tests::test_resume_across_page_boundary_no_drop_no_double`,
|
|
/// `test_object_with_versions_spanning_pages_advances`,
|
|
/// `test_non_advancing_cursor_aborts`,
|
|
/// `test_schedule_retry_resets_both_managers_and_reheals`). Here we prove the
|
|
/// same resume machinery drives a real ECStore heal to completion and repairs
|
|
/// every version, with the resume state cleaned up afterward.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[serial]
|
|
async fn test_heal_resume_across_page_boundary_e2e() {
|
|
let (disk_paths, ecstore, heal_storage) = heal_env().await;
|
|
let bucket = "b5-resume-e2e";
|
|
create_versioned_bucket(&ecstore, bucket).await;
|
|
|
|
// A small multi-object, multi-version fixture: 3 objects, 2 versions each,
|
|
// plus a delete-marker-latest on one of them.
|
|
let mut versions: Vec<(String, String, Vec<u8>)> = Vec::new(); // (object, version_id, data)
|
|
for obj_idx in 0..3u8 {
|
|
let object = format!("obj-{obj_idx}.bin");
|
|
let d1 = versioned_test_data(obj_idx + 50);
|
|
let d2 = versioned_test_data(obj_idx + 80);
|
|
let v1 = put_versioned(&ecstore, bucket, &object, &d1).await;
|
|
let v2 = put_versioned(&ecstore, bucket, &object, &d2).await;
|
|
versions.push((object.clone(), v1, d1));
|
|
versions.push((object.clone(), v2, d2));
|
|
}
|
|
// Delete-marker latest on obj-0.
|
|
let _dm = put_delete_marker(&ecstore, bucket, "obj-0.bin").await;
|
|
|
|
// Wipe disk[0]'s bucket dir entirely (all objects' shards + metadata).
|
|
let bucket_dir0 = disk_paths[0].join(bucket);
|
|
assert!(bucket_dir0.exists());
|
|
std::fs::remove_dir_all(&bucket_dir0).expect("failed to wipe bucket dir on disk[0]");
|
|
assert!(!bucket_dir0.exists());
|
|
|
|
// Drive the resume-backed erasure-set heal through the HealManager, which
|
|
// constructs the ErasureSetHealer and runs heal_bucket_with_resume.
|
|
let cfg = HealConfig {
|
|
heal_interval: Duration::from_millis(1),
|
|
..Default::default()
|
|
};
|
|
let heal_manager = HealManager::new(heal_storage.clone(), Some(cfg));
|
|
heal_manager.start().await.unwrap();
|
|
let request = HealRequest::new(
|
|
HealType::ErasureSet {
|
|
buckets: vec![bucket.to_string()],
|
|
set_disk_id: SET_DISK_ID.to_string(),
|
|
},
|
|
HealOptions {
|
|
recursive: true,
|
|
recreate_missing: true,
|
|
scan_mode: HealScanMode::Normal,
|
|
timeout: Some(Duration::from_secs(300)),
|
|
..Default::default()
|
|
},
|
|
HealPriority::Normal,
|
|
);
|
|
let task_id = request.id.clone();
|
|
let admission = heal_manager
|
|
.submit_heal_request(request)
|
|
.await
|
|
.expect("failed to submit erasure-set heal");
|
|
assert!(admission.is_admitted(), "erasure-set heal must be admitted");
|
|
|
|
wait_for_task(&heal_manager, &task_id, Duration::from_secs(120)).await;
|
|
|
|
// Every data version is readable end-to-end and physically restored on the
|
|
// wiped disk. (Resume machinery drove the full per-version heal.)
|
|
for (object, version_id, data) in &versions {
|
|
assert_eq!(
|
|
&read_version(&ecstore, bucket, object, version_id).await,
|
|
data,
|
|
"{object} v={version_id}: data not restored after resume-backed heal"
|
|
);
|
|
let dir = object_dir(&disk_paths[0], bucket, object);
|
|
assert!(xl_meta_path(&dir).exists(), "{object}: xl.meta not restored on disk[0]");
|
|
}
|
|
// Each object should have both of its data versions' shards back on disk[0].
|
|
for obj_idx in 0..3u8 {
|
|
let dir = object_dir(&disk_paths[0], bucket, &format!("obj-{obj_idx}.bin"));
|
|
assert_eq!(
|
|
count_part_files(&dir),
|
|
2,
|
|
"obj-{obj_idx}: both data versions' shards must be restored on disk[0]"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Poll a heal task to a terminal state, panicking on failure/timeout.
|
|
async fn wait_for_task(heal_manager: &HealManager, task_id: &str, timeout: Duration) {
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
if let Ok(status) = heal_manager.get_task_status(task_id).await {
|
|
match status {
|
|
HealTaskStatus::Completed => return,
|
|
HealTaskStatus::Failed { ref error } => panic!("heal task failed: {error}"),
|
|
HealTaskStatus::Cancelled => panic!("heal task was cancelled"),
|
|
_ => {}
|
|
}
|
|
}
|
|
if tokio::time::Instant::now() >= deadline {
|
|
panic!("heal task {task_id} did not complete within {timeout:?}");
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
}
|