mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-13 16:46:55 +00:00
test(heal): strengthen replacement e2e evidence (#5956)
* test(heal): strengthen replacement e2e evidence * test(heal): fix replacement e2e barriers Accept the real post-fault scanner failure-to-idle sequence as the live disk loss barrier, and preserve the first definitive completed status while only resampling the physical census for premature-completion confirmation. Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: houseme <housemecn@gmail.com> Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
@@ -62,8 +62,8 @@ pub(crate) struct VersionShardCensus {
|
|||||||
pub data_dir: Option<String>,
|
pub data_dir: Option<String>,
|
||||||
pub erasure_index: Option<usize>,
|
pub erasure_index: Option<usize>,
|
||||||
pub expected_part_numbers: BTreeSet<usize>,
|
pub expected_part_numbers: BTreeSet<usize>,
|
||||||
pub present_part_numbers: BTreeSet<usize>,
|
|
||||||
pub present_part_fingerprints: BTreeMap<usize, PartShardFingerprint>,
|
pub present_part_fingerprints: BTreeMap<usize, PartShardFingerprint>,
|
||||||
|
pub inline_data_fingerprint: Option<PartShardFingerprint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -74,7 +74,12 @@ pub(crate) struct PartShardFingerprint {
|
|||||||
|
|
||||||
impl VersionShardCensus {
|
impl VersionShardCensus {
|
||||||
pub(crate) fn is_complete(&self) -> bool {
|
pub(crate) fn is_complete(&self) -> bool {
|
||||||
self.has_xl_meta && self.expected_part_numbers == self.present_part_numbers
|
self.has_xl_meta
|
||||||
|
&& self.expected_part_numbers.len() == self.present_part_fingerprints.len()
|
||||||
|
&& self
|
||||||
|
.expected_part_numbers
|
||||||
|
.iter()
|
||||||
|
.all(|part_number| self.present_part_fingerprints.contains_key(part_number))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn matches_manifest(&self, manifest: &Self) -> bool {
|
pub(crate) fn matches_manifest(&self, manifest: &Self) -> bool {
|
||||||
@@ -85,6 +90,7 @@ impl VersionShardCensus {
|
|||||||
&& self.erasure_index == manifest.erasure_index
|
&& self.erasure_index == manifest.erasure_index
|
||||||
&& self.expected_part_numbers == manifest.expected_part_numbers
|
&& self.expected_part_numbers == manifest.expected_part_numbers
|
||||||
&& self.present_part_fingerprints == manifest.present_part_fingerprints
|
&& self.present_part_fingerprints == manifest.present_part_fingerprints
|
||||||
|
&& self.inline_data_fingerprint == manifest.inline_data_fingerprint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +99,13 @@ fn sha256_hex(data: &[u8]) -> String {
|
|||||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shard_fingerprint(data: &[u8]) -> ChaosResult<PartShardFingerprint> {
|
||||||
|
Ok(PartShardFingerprint {
|
||||||
|
size: u64::try_from(data.len())?,
|
||||||
|
sha256: sha256_hex(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Single-node RustFS server with `disk_count` local volume directories that
|
/// Single-node RustFS server with `disk_count` local volume directories that
|
||||||
/// can be faulted individually while the server is running.
|
/// can be faulted individually while the server is running.
|
||||||
pub struct DiskFaultHarness {
|
pub struct DiskFaultHarness {
|
||||||
@@ -301,8 +314,8 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
data_dir: None,
|
data_dir: None,
|
||||||
erasure_index: None,
|
erasure_index: None,
|
||||||
expected_part_numbers: BTreeSet::new(),
|
expected_part_numbers: BTreeSet::new(),
|
||||||
present_part_numbers: BTreeSet::new(),
|
|
||||||
present_part_fingerprints: BTreeMap::new(),
|
present_part_fingerprints: BTreeMap::new(),
|
||||||
|
inline_data_fingerprint: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,10 +328,10 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
};
|
};
|
||||||
let data_dir = file_info.data_dir.map(|id| id.to_string());
|
let data_dir = file_info.data_dir.map(|id| id.to_string());
|
||||||
let erasure_index = Some(file_info.erasure.index);
|
let erasure_index = Some(file_info.erasure.index);
|
||||||
|
let inline_data_fingerprint = file_info.data.as_deref().map(shard_fingerprint).transpose()?;
|
||||||
let part_dir = data_dir.as_ref().map_or_else(|| object_dir.clone(), |id| object_dir.join(id));
|
let part_dir = data_dir.as_ref().map_or_else(|| object_dir.clone(), |id| object_dir.join(id));
|
||||||
let (present_part_numbers, present_part_fingerprints) = match std::fs::read_dir(&part_dir) {
|
let present_part_fingerprints = match std::fs::read_dir(&part_dir) {
|
||||||
Ok(entries) => {
|
Ok(entries) => {
|
||||||
let mut numbers = BTreeSet::new();
|
|
||||||
let mut fingerprints = BTreeMap::new();
|
let mut fingerprints = BTreeMap::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
@@ -333,19 +346,12 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
numbers.insert(part_number);
|
|
||||||
let data = std::fs::read(entry.path())?;
|
let data = std::fs::read(entry.path())?;
|
||||||
fingerprints.insert(
|
fingerprints.insert(part_number, shard_fingerprint(&data)?);
|
||||||
part_number,
|
|
||||||
PartShardFingerprint {
|
|
||||||
size: u64::try_from(data.len())?,
|
|
||||||
sha256: sha256_hex(&data),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
(numbers, fingerprints)
|
fingerprints
|
||||||
}
|
}
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => (BTreeSet::new(), BTreeMap::new()),
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
|
||||||
Err(error) => return Err(error.into()),
|
Err(error) => return Err(error.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -355,8 +361,8 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
data_dir,
|
data_dir,
|
||||||
erasure_index,
|
erasure_index,
|
||||||
expected_part_numbers,
|
expected_part_numbers,
|
||||||
present_part_numbers,
|
|
||||||
present_part_fingerprints,
|
present_part_fingerprints,
|
||||||
|
inline_data_fingerprint,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,3 +402,43 @@ pub async fn signed_admin_post(url: &str, body: Option<&str>, access_key: &str,
|
|||||||
|
|
||||||
Ok(body)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn complete_census() -> VersionShardCensus {
|
||||||
|
VersionShardCensus {
|
||||||
|
version_id: Some("version".to_string()),
|
||||||
|
has_xl_meta: true,
|
||||||
|
data_dir: Some("data-dir".to_string()),
|
||||||
|
erasure_index: Some(3),
|
||||||
|
expected_part_numbers: BTreeSet::from([1]),
|
||||||
|
present_part_fingerprints: BTreeMap::from([(1, shard_fingerprint(b"part").unwrap())]),
|
||||||
|
inline_data_fingerprint: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shard_fingerprint_uses_physical_length_and_sha256() {
|
||||||
|
assert_eq!(
|
||||||
|
shard_fingerprint(b"abc").unwrap(),
|
||||||
|
PartShardFingerprint {
|
||||||
|
size: 3,
|
||||||
|
sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_requires_matching_inline_payload() {
|
||||||
|
let mut expected = complete_census();
|
||||||
|
expected.expected_part_numbers.clear();
|
||||||
|
expected.present_part_fingerprints.clear();
|
||||||
|
expected.inline_data_fingerprint = Some(shard_fingerprint(b"expected").unwrap());
|
||||||
|
let mut changed = expected.clone();
|
||||||
|
changed.inline_data_fingerprint = Some(shard_fingerprint(b"changed").unwrap());
|
||||||
|
assert!(expected.matches_manifest(&expected));
|
||||||
|
assert!(!changed.matches_manifest(&expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -349,11 +349,32 @@ mod tests {
|
|||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let first_inline = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("versions/inline.bin")
|
||||||
|
.body(ByteStream::from(payload(8 * 1024, 40)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let first_inline_version = first_inline
|
||||||
|
.version_id()
|
||||||
|
.ok_or("first inline PUT did not return a version ID")?;
|
||||||
|
let second_inline = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("versions/inline.bin")
|
||||||
|
.body(ByteStream::from(payload(8 * 1024, 41)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let second_inline_version = second_inline
|
||||||
|
.version_id()
|
||||||
|
.ok_or("second inline PUT did not return a version ID")?;
|
||||||
|
|
||||||
let first = client
|
let first = client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.body(ByteStream::from(payload(256 * 1024, 41)))
|
.body(ByteStream::from(payload(128 * 1024, 41)))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
let first_version = first.version_id().ok_or("first PUT did not return a version ID")?;
|
let first_version = first.version_id().ok_or("first PUT did not return a version ID")?;
|
||||||
@@ -361,16 +382,36 @@ mod tests {
|
|||||||
.put_object()
|
.put_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.body(ByteStream::from(payload(256 * 1024, 42)))
|
.body(ByteStream::from(payload(3 * 1024 * 1024, 42)))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
let second_version = second.version_id().ok_or("second PUT did not return a version ID")?;
|
let second_version = second.version_id().ok_or("second PUT did not return a version ID")?;
|
||||||
let delete = client.delete_object().bucket(bucket).key(key).send().await?;
|
let delete = client.delete_object().bucket(bucket).key(key).send().await?;
|
||||||
let delete_version = delete.version_id().ok_or("delete marker did not return a version ID")?;
|
let delete_version = delete.version_id().ok_or("delete marker did not return a version ID")?;
|
||||||
|
|
||||||
|
let first_inline_census = harness.census_object_version(0, bucket, "versions/inline.bin", Some(first_inline_version))?;
|
||||||
|
let second_inline_census =
|
||||||
|
harness.census_object_version(0, bucket, "versions/inline.bin", Some(second_inline_version))?;
|
||||||
let first_census = harness.census_object_version(0, bucket, key, Some(first_version))?;
|
let first_census = harness.census_object_version(0, bucket, key, Some(first_version))?;
|
||||||
|
let first_other_disk_census = harness.census_object_version(1, bucket, key, Some(first_version))?;
|
||||||
let second_census = harness.census_object_version(0, bucket, key, Some(second_version))?;
|
let second_census = harness.census_object_version(0, bucket, key, Some(second_version))?;
|
||||||
let delete_census = harness.census_object_version(0, bucket, key, Some(delete_version))?;
|
let delete_census = harness.census_object_version(0, bucket, key, Some(delete_version))?;
|
||||||
|
assert!(
|
||||||
|
first_inline_census.is_complete() && second_inline_census.is_complete(),
|
||||||
|
"inline version physical census is incomplete: first={first_inline_census:?} second={second_inline_census:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
first_inline_census.present_part_fingerprints.is_empty() && second_inline_census.present_part_fingerprints.is_empty(),
|
||||||
|
"inline versions must not select external shard files: first={first_inline_census:?} second={second_inline_census:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
first_inline_census.inline_data_fingerprint.is_some() && second_inline_census.inline_data_fingerprint.is_some(),
|
||||||
|
"inline versions must fingerprint payload bytes stored in xl.meta"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
first_inline_census.inline_data_fingerprint, second_inline_census.inline_data_fingerprint,
|
||||||
|
"same-size inline versions with different payloads must retain distinct xl.meta fingerprints"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
first_census.is_complete(),
|
first_census.is_complete(),
|
||||||
"first version physical census is incomplete: {first_census:?}"
|
"first version physical census is incomplete: {first_census:?}"
|
||||||
@@ -379,6 +420,14 @@ mod tests {
|
|||||||
second_census.is_complete(),
|
second_census.is_complete(),
|
||||||
"second version physical census is incomplete: {second_census:?}"
|
"second version physical census is incomplete: {second_census:?}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
first_other_disk_census.is_complete(),
|
||||||
|
"first version physical census on the second disk is incomplete: {first_other_disk_census:?}"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
first_census.erasure_index, first_other_disk_census.erasure_index,
|
||||||
|
"physical census must preserve each disk's erasure index"
|
||||||
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
first_census.data_dir, second_census.data_dir,
|
first_census.data_dir, second_census.data_dir,
|
||||||
"distinct object versions must select distinct physical data directories"
|
"distinct object versions must select distinct physical data directories"
|
||||||
@@ -387,6 +436,24 @@ mod tests {
|
|||||||
first_census.expected_part_numbers, second_census.expected_part_numbers,
|
first_census.expected_part_numbers, second_census.expected_part_numbers,
|
||||||
"same single-part shape should expose the same part numbers"
|
"same single-part shape should expose the same part numbers"
|
||||||
);
|
);
|
||||||
|
let first_part = first_census
|
||||||
|
.present_part_fingerprints
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.ok_or("first version did not expose a physical part fingerprint")?;
|
||||||
|
let second_part = second_census
|
||||||
|
.present_part_fingerprints
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.ok_or("second version did not expose a physical part fingerprint")?;
|
||||||
|
assert_ne!(
|
||||||
|
first_part.size, second_part.size,
|
||||||
|
"different shard lengths must retain their physical sizes"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
first_part.sha256, second_part.sha256,
|
||||||
|
"different shard contents must retain their physical hashes"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
delete_census.is_complete(),
|
delete_census.is_complete(),
|
||||||
"delete marker physical census is incomplete: {delete_census:?}"
|
"delete marker physical census is incomplete: {delete_census:?}"
|
||||||
@@ -396,7 +463,7 @@ mod tests {
|
|||||||
"delete marker must not declare object shards: {delete_census:?}"
|
"delete marker must not declare object shards: {delete_census:?}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
delete_census.present_part_numbers.is_empty(),
|
delete_census.present_part_fingerprints.is_empty(),
|
||||||
"delete marker must not select stale object shards: {delete_census:?}"
|
"delete marker must not select stale object shards: {delete_census:?}"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ mod tests {
|
|||||||
expected: VersionShardCensus,
|
expected: VersionShardCensus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
enum CompletionSample {
|
||||||
|
Pending,
|
||||||
|
Ready,
|
||||||
|
CompletedWithIncomplete(BTreeSet<String>),
|
||||||
|
}
|
||||||
|
|
||||||
struct MountNamespaceGuard {
|
struct MountNamespaceGuard {
|
||||||
mounts: Vec<PathBuf>,
|
mounts: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
@@ -75,7 +82,7 @@ mod tests {
|
|||||||
impl MountNamespaceGuard {
|
impl MountNamespaceGuard {
|
||||||
fn new() -> Result<Self, Box<dyn Error + Send + Sync>> {
|
fn new() -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||||
verify_isolated_mount_namespace()?;
|
verify_isolated_mount_namespace()?;
|
||||||
run_command("mount", ["--make-rprivate", "/"])?;
|
run_command("mount", &["--make-rprivate", "/"])?;
|
||||||
Ok(Self { mounts: Vec::new() })
|
Ok(Self { mounts: Vec::new() })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,15 +115,15 @@ mod tests {
|
|||||||
return Err("losetup --find --show returned an empty loop device".into());
|
return Err("losetup --find --show returned an empty loop device".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
run_command_dynamic("mkfs.ext4", &["-F", &loop_device])?;
|
run_command("mkfs.ext4", &["-F", &loop_device])?;
|
||||||
let sectors = run_command_stdout("blockdev", &["--getsz", &loop_device])?;
|
let sectors = run_command_stdout("blockdev", &["--getsz", &loop_device])?;
|
||||||
let dm_name = format!("rustfs_e2e_{label}_{}", std::process::id());
|
let dm_name = format!("rustfs_e2e_{label}_{}", std::process::id());
|
||||||
let table = format!("0 {sectors} linear {loop_device} 0");
|
let table = format!("0 {sectors} linear {loop_device} 0");
|
||||||
let mapper = format!("/dev/mapper/{dm_name}");
|
let mapper = format!("/dev/mapper/{dm_name}");
|
||||||
run_command_dynamic("dmsetup", &["create", &dm_name, "--table", &table])?;
|
run_command("dmsetup", &["create", &dm_name, "--table", &table])?;
|
||||||
|
|
||||||
let target_arg = path_to_string(target, "faultable mount target")?;
|
let target_arg = path_to_string(target, "faultable mount target")?;
|
||||||
run_command_dynamic("mount", &[&mapper, &target_arg])?;
|
run_command("mount", &[&mapper, &target_arg])?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
target: target.to_path_buf(),
|
target: target.to_path_buf(),
|
||||||
@@ -131,17 +138,17 @@ mod tests {
|
|||||||
fn make_unavailable(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
fn make_unavailable(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
let sectors = run_command_stdout("blockdev", &["--getsz", &self.loop_device])?;
|
let sectors = run_command_stdout("blockdev", &["--getsz", &self.loop_device])?;
|
||||||
let error_table = format!("0 {sectors} error");
|
let error_table = format!("0 {sectors} error");
|
||||||
run_command_dynamic("dmsetup", &["suspend", &self.dm_name])?;
|
run_command("dmsetup", &["suspend", &self.dm_name])?;
|
||||||
run_command_dynamic("dmsetup", &["load", &self.dm_name, "--table", &error_table])?;
|
run_command("dmsetup", &["load", &self.dm_name, "--table", &error_table])?;
|
||||||
run_command_dynamic("dmsetup", &["resume", &self.dm_name])
|
run_command("dmsetup", &["resume", &self.dm_name])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
let sectors = run_command_stdout("blockdev", &["--getsz", &self.loop_device])?;
|
let sectors = run_command_stdout("blockdev", &["--getsz", &self.loop_device])?;
|
||||||
let linear_table = format!("0 {sectors} linear {} 0", self.loop_device);
|
let linear_table = format!("0 {sectors} linear {} 0", self.loop_device);
|
||||||
run_command_dynamic("dmsetup", &["suspend", &self.dm_name])?;
|
run_command("dmsetup", &["suspend", &self.dm_name])?;
|
||||||
run_command_dynamic("dmsetup", &["load", &self.dm_name, "--table", &linear_table])?;
|
run_command("dmsetup", &["load", &self.dm_name, "--table", &linear_table])?;
|
||||||
run_command_dynamic("dmsetup", &["resume", &self.dm_name])
|
run_command("dmsetup", &["resume", &self.dm_name])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cleanup(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
fn cleanup(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
@@ -157,14 +164,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.dm_created {
|
if self.dm_created {
|
||||||
if let Err(error) = run_command_dynamic("dmsetup", &["remove", "-f", &self.dm_name]) {
|
if let Err(error) = run_command("dmsetup", &["remove", "-f", &self.dm_name]) {
|
||||||
first_error.get_or_insert(error);
|
first_error.get_or_insert(error);
|
||||||
} else {
|
} else {
|
||||||
self.dm_created = false;
|
self.dm_created = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !self.loop_device.is_empty() {
|
if !self.loop_device.is_empty() {
|
||||||
if let Err(error) = run_command_dynamic("losetup", &["-d", &self.loop_device]) {
|
if let Err(error) = run_command("losetup", &["-d", &self.loop_device]) {
|
||||||
first_error.get_or_insert(error);
|
first_error.get_or_insert(error);
|
||||||
} else {
|
} else {
|
||||||
self.loop_device.clear();
|
self.loop_device.clear();
|
||||||
@@ -188,14 +195,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_command<const N: usize>(program: &str, args: [&str; N]) -> Result<(), Box<dyn Error + Send + Sync>> {
|
fn checked_command_output(program: &str, args: &[&str]) -> Result<std::process::Output, Box<dyn Error + Send + Sync>> {
|
||||||
run_command_dynamic(program, &args)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_command_dynamic(program: &str, args: &[&str]) -> Result<(), Box<dyn Error + Send + Sync>> {
|
|
||||||
let output = Command::new(program).args(args).output()?;
|
let output = Command::new(program).args(args).output()?;
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
return Ok(());
|
return Ok(output);
|
||||||
}
|
}
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"{program} {} failed with status {}: stdout={} stderr={}",
|
"{program} {} failed with status {}: stdout={} stderr={}",
|
||||||
@@ -207,19 +210,14 @@ mod tests {
|
|||||||
.into())
|
.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_command(program: &str, args: &[&str]) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
checked_command_output(program, args).map(drop)
|
||||||
|
}
|
||||||
|
|
||||||
fn run_command_stdout(program: &str, args: &[&str]) -> Result<String, Box<dyn Error + Send + Sync>> {
|
fn run_command_stdout(program: &str, args: &[&str]) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||||
let output = Command::new(program).args(args).output()?;
|
Ok(String::from_utf8(checked_command_output(program, args)?.stdout)?
|
||||||
if output.status.success() {
|
.trim()
|
||||||
return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
|
.to_string())
|
||||||
}
|
|
||||||
Err(format!(
|
|
||||||
"{program} {} failed with status {}: stdout={} stderr={}",
|
|
||||||
args.join(" "),
|
|
||||||
output.status,
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
)
|
|
||||||
.into())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn path_to_string(path: &Path, label: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
|
fn path_to_string(path: &Path, label: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||||
@@ -260,14 +258,14 @@ mod tests {
|
|||||||
let target = target
|
let target = target
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or_else(|| format!("tmpfs target path is not UTF-8: {target:?}"))?;
|
.ok_or_else(|| format!("tmpfs target path is not UTF-8: {target:?}"))?;
|
||||||
run_command("mount", ["-t", "tmpfs", "-o", MOUNT_SIZE, label, target])
|
run_command("mount", &["-t", "tmpfs", "-o", MOUNT_SIZE, label, target])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detach_mount(target: &Path) -> Result<(), Box<dyn Error + Send + Sync>> {
|
fn detach_mount(target: &Path) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
let target = target
|
let target = target
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or_else(|| format!("umount target path is not UTF-8: {target:?}"))?;
|
.ok_or_else(|| format!("umount target path is not UTF-8: {target:?}"))?;
|
||||||
run_command("umount", [target])
|
run_command("umount", &[target])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn privileged_run_enabled() -> Result<bool, Box<dyn Error + Send + Sync>> {
|
fn privileged_run_enabled() -> Result<bool, Box<dyn Error + Send + Sync>> {
|
||||||
@@ -423,6 +421,10 @@ mod tests {
|
|||||||
.await?;
|
.await?;
|
||||||
versions.push((versioned_bucket, "history/object.bin", deleted.version_id().map(str::to_owned), None));
|
versions.push((versioned_bucket, "history/object.bin", deleted.version_id().map(str::to_owned), None));
|
||||||
|
|
||||||
|
let (version_id, body_sha256) =
|
||||||
|
put_object_version(client, versioned_bucket, "history/inline.bin", payload(8 * 1024, 9)).await?;
|
||||||
|
versions.push((versioned_bucket, "history/inline.bin", version_id, Some(body_sha256)));
|
||||||
|
|
||||||
let (version_id, body_sha256) = put_multipart_version(
|
let (version_id, body_sha256) = put_multipart_version(
|
||||||
client,
|
client,
|
||||||
versioned_bucket,
|
versioned_bucket,
|
||||||
@@ -436,7 +438,7 @@ mod tests {
|
|||||||
put_object_version(client, null_bucket, "null/current.bin", payload(512 * 1024, 8)).await?;
|
put_object_version(client, null_bucket, "null/current.bin", payload(512 * 1024, 8)).await?;
|
||||||
versions.push((null_bucket, "null/current.bin", version_id, Some(body_sha256)));
|
versions.push((null_bucket, "null/current.bin", version_id, Some(body_sha256)));
|
||||||
|
|
||||||
versions
|
let versions = versions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(bucket, key, version_id, body_sha256)| {
|
.map(|(bucket, key, version_id, body_sha256)| {
|
||||||
let expected = census_object_version_on_disk(target_disk, bucket, key, version_id.as_deref())?;
|
let expected = census_object_version_on_disk(target_disk, bucket, key, version_id.as_deref())?;
|
||||||
@@ -451,7 +453,15 @@ mod tests {
|
|||||||
expected,
|
expected,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect::<Result<Vec<_>, Box<dyn Error + Send + Sync>>>()?;
|
||||||
|
let inline = versions
|
||||||
|
.iter()
|
||||||
|
.find(|version| version.key == "history/inline.bin")
|
||||||
|
.ok_or("inline replacement baseline was not recorded")?;
|
||||||
|
if inline.expected.inline_data_fingerprint.is_none() || !inline.expected.present_part_fingerprints.is_empty() {
|
||||||
|
return Err(format!("inline replacement baseline lacks xl.meta payload evidence: {:?}", inline.expected).into());
|
||||||
|
}
|
||||||
|
Ok(versions)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn verify_bodies(client: &Client, versions: &[BaselineVersion]) -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn verify_bodies(client: &Client, versions: &[BaselineVersion]) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
@@ -597,6 +607,33 @@ mod tests {
|
|||||||
Ok(String::from_utf8_lossy(&log[start..]).into_owned())
|
Ok(String::from_utf8_lossy(&log[start..]).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn live_disk_loss_scan_completed(log: &str, target_disk: &Path) -> bool {
|
||||||
|
let target = target_disk.to_string_lossy();
|
||||||
|
let mut saw_live_loss = false;
|
||||||
|
for line in log.lines() {
|
||||||
|
if line.contains("Heal auto-scan disk inspection failed")
|
||||||
|
&& line.contains("check_failed")
|
||||||
|
&& line.contains(target.as_ref())
|
||||||
|
{
|
||||||
|
saw_live_loss = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if saw_live_loss && (line.contains("Heal auto disk scanner idle") || line.contains("Heal auto-scan cycle completed"))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn live_disk_loss_scan_completed_from_path(
|
||||||
|
log_path: &Path,
|
||||||
|
start_offset: u64,
|
||||||
|
target_disk: &Path,
|
||||||
|
) -> Result<bool, Box<dyn Error + Send + Sync>> {
|
||||||
|
Ok(live_disk_loss_scan_completed(&log_from_offset(log_path, start_offset)?, target_disk))
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_live_disk_loss_observation(
|
async fn wait_for_live_disk_loss_observation(
|
||||||
log_path: &Path,
|
log_path: &Path,
|
||||||
target_disk: &Path,
|
target_disk: &Path,
|
||||||
@@ -605,20 +642,12 @@ mod tests {
|
|||||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
||||||
let mut tick = interval(Duration::from_secs(1));
|
let mut tick = interval(Duration::from_secs(1));
|
||||||
let target = target_disk.to_string_lossy();
|
|
||||||
loop {
|
loop {
|
||||||
let log = log_from_offset(log_path, start_offset)?;
|
if live_disk_loss_scan_completed_from_path(log_path, start_offset, target_disk)? {
|
||||||
let mut saw_live_loss = false;
|
return Ok(());
|
||||||
for line in log.lines() {
|
|
||||||
if line.contains("check_failed") && line.contains(target.as_ref()) {
|
|
||||||
saw_live_loss = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if saw_live_loss && line.contains("Heal auto disk scanner idle") {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
|
let log = log_from_offset(log_path, start_offset)?;
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"scanner did not finish a live target-loss scan for {target_disk:?} within {timeout_secs}s; log tail:\n{}",
|
"scanner did not finish a live target-loss scan for {target_disk:?} within {timeout_secs}s; log tail:\n{}",
|
||||||
log_tail(&log)
|
log_tail(&log)
|
||||||
@@ -629,14 +658,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_definitive_replacement_status(
|
fn cluster_status_is_definitive(status: &serde_json::Value) -> Result<bool, Box<dyn Error + Send + Sync>> {
|
||||||
status: &serde_json::Value,
|
status["cluster"]["definitive"]
|
||||||
context: &str,
|
.as_bool()
|
||||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
.ok_or_else(|| format!("replacement recovery status omitted cluster.definitive: {status}").into())
|
||||||
if status["cluster"]["definitive"].as_bool().unwrap_or(false) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(format!("{context} requires a definitive cluster replacement status: {status}").into())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn assert_no_replacement_status_records(
|
async fn assert_no_replacement_status_records(
|
||||||
@@ -644,8 +669,17 @@ mod tests {
|
|||||||
target_disk: &Path,
|
target_disk: &Path,
|
||||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
let status = replacement_status(cluster).await?;
|
let status = replacement_status(cluster).await?;
|
||||||
require_definitive_replacement_status(&status, "live missing replacement status check")?;
|
assert_no_replacement_status_records_in_status(&status, target_disk)
|
||||||
let states = target_record_states(&status, target_disk);
|
}
|
||||||
|
|
||||||
|
fn assert_no_replacement_status_records_in_status(
|
||||||
|
status: &serde_json::Value,
|
||||||
|
target_disk: &Path,
|
||||||
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
if !cluster_status_is_definitive(status)? {
|
||||||
|
return Err(format!("live missing replacement status check requires a definitive cluster status: {status}").into());
|
||||||
|
}
|
||||||
|
let states = target_record_states(status, target_disk);
|
||||||
if states.is_empty() {
|
if states.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -655,11 +689,6 @@ mod tests {
|
|||||||
.into())
|
.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn target_record_has_state(status: &serde_json::Value, target_disk: &Path, states: &[&str]) -> bool {
|
|
||||||
let present_states = target_record_states(status, target_disk);
|
|
||||||
states.iter().any(|state| present_states.contains(*state))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn target_record_states(status: &serde_json::Value, target_disk: &Path) -> BTreeSet<String> {
|
fn target_record_states(status: &serde_json::Value, target_disk: &Path) -> BTreeSet<String> {
|
||||||
let target = target_disk.to_string_lossy();
|
let target = target_disk.to_string_lossy();
|
||||||
status["cluster"]["records"]
|
status["cluster"]["records"]
|
||||||
@@ -694,6 +723,57 @@ mod tests {
|
|||||||
Ok(missing)
|
Ok(missing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replacement_completion_state(
|
||||||
|
status: &serde_json::Value,
|
||||||
|
target_disk: &Path,
|
||||||
|
missing: BTreeSet<String>,
|
||||||
|
) -> Result<CompletionSample, Box<dyn Error + Send + Sync>> {
|
||||||
|
if !cluster_status_is_definitive(status)? {
|
||||||
|
return Ok(CompletionSample::Pending);
|
||||||
|
}
|
||||||
|
if !target_record_states(status, target_disk).contains("completed") {
|
||||||
|
return Ok(CompletionSample::Pending);
|
||||||
|
}
|
||||||
|
if missing.is_empty() {
|
||||||
|
return Ok(CompletionSample::Ready);
|
||||||
|
}
|
||||||
|
Ok(CompletionSample::CompletedWithIncomplete(missing))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sample_replacement_completion<C, S, F>(
|
||||||
|
target_disk: &Path,
|
||||||
|
census: C,
|
||||||
|
status: S,
|
||||||
|
) -> Result<CompletionSample, Box<dyn Error + Send + Sync>>
|
||||||
|
where
|
||||||
|
C: FnOnce() -> Result<BTreeSet<String>, Box<dyn Error + Send + Sync>>,
|
||||||
|
S: FnOnce() -> F,
|
||||||
|
F: std::future::Future<Output = Result<serde_json::Value, Box<dyn Error + Send + Sync>>>,
|
||||||
|
{
|
||||||
|
let missing = census()?;
|
||||||
|
let status = status().await?;
|
||||||
|
replacement_completion_state(&status, target_disk, missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn confirm_replacement_completion<C, S, F>(
|
||||||
|
target_disk: &Path,
|
||||||
|
mut census: C,
|
||||||
|
mut status: S,
|
||||||
|
) -> Result<CompletionSample, Box<dyn Error + Send + Sync>>
|
||||||
|
where
|
||||||
|
C: FnMut() -> Result<BTreeSet<String>, Box<dyn Error + Send + Sync>>,
|
||||||
|
S: FnMut() -> F,
|
||||||
|
F: std::future::Future<Output = Result<serde_json::Value, Box<dyn Error + Send + Sync>>>,
|
||||||
|
{
|
||||||
|
let missing = census()?;
|
||||||
|
let status = status().await?;
|
||||||
|
let first = replacement_completion_state(&status, target_disk, missing)?;
|
||||||
|
if matches!(first, CompletionSample::CompletedWithIncomplete(_)) {
|
||||||
|
return replacement_completion_state(&status, target_disk, census()?);
|
||||||
|
}
|
||||||
|
Ok(first)
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_completed_replacement_with_census(
|
async fn wait_for_completed_replacement_with_census(
|
||||||
cluster: &RustFSTestClusterEnvironment,
|
cluster: &RustFSTestClusterEnvironment,
|
||||||
target_disk: &Path,
|
target_disk: &Path,
|
||||||
@@ -703,28 +783,25 @@ mod tests {
|
|||||||
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
||||||
let mut tick = interval(Duration::from_secs(1));
|
let mut tick = interval(Duration::from_secs(1));
|
||||||
loop {
|
loop {
|
||||||
let status = replacement_status(cluster).await?;
|
match confirm_replacement_completion(
|
||||||
let missing = incomplete_versions(target_disk, versions)?;
|
target_disk,
|
||||||
if require_definitive_replacement_status(&status, "replacement completion poll").is_err() {
|
|| incomplete_versions(target_disk, versions),
|
||||||
if Instant::now() >= deadline {
|
|| replacement_status(cluster),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
CompletionSample::Ready => return Ok(()),
|
||||||
|
CompletionSample::CompletedWithIncomplete(confirmed_missing) => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"replacement recovery status never became definitive within {timeout_secs}s while waiting for physical census; latest status: {status}; missing: {missing:?}"
|
"replacement status remained completed across two incomplete physical censuses: {confirmed_missing:?}"
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
tick.tick().await;
|
CompletionSample::Pending => {}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if target_record_has_state(&status, target_disk, &["completed"]) {
|
|
||||||
if !missing.is_empty() {
|
|
||||||
return Err(format!(
|
|
||||||
"replacement status reached completed before target physical census matched baseline: {missing:?}; status: {status}"
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
|
let missing = incomplete_versions(target_disk, versions)?;
|
||||||
|
let status = replacement_status(cluster).await?;
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"replacement target did not reach completed with matching physical census within {timeout_secs}s: missing={missing:?}; status={status}"
|
"replacement target did not reach completed with matching physical census within {timeout_secs}s: missing={missing:?}; status={status}"
|
||||||
)
|
)
|
||||||
@@ -812,6 +889,175 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_loss_barrier_requires_scanner_failure_after_log_offset() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
let target = Path::new("/mnt/target");
|
||||||
|
assert!(live_disk_loss_scan_completed(
|
||||||
|
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto-scan cycle completed",
|
||||||
|
target
|
||||||
|
));
|
||||||
|
assert!(live_disk_loss_scan_completed(
|
||||||
|
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle",
|
||||||
|
target
|
||||||
|
));
|
||||||
|
assert!(!live_disk_loss_scan_completed(
|
||||||
|
"Heal auto disk scanner idle\nHeal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed",
|
||||||
|
target
|
||||||
|
));
|
||||||
|
assert!(!live_disk_loss_scan_completed(
|
||||||
|
"event=disk_health_check_failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle",
|
||||||
|
target
|
||||||
|
));
|
||||||
|
assert!(!live_disk_loss_scan_completed(
|
||||||
|
"Heal auto-scan disk inspection failed endpoint=/mnt/other disk_state=check_failed\nHeal auto disk scanner idle",
|
||||||
|
target
|
||||||
|
));
|
||||||
|
let path = std::env::temp_dir().join(format!("rustfs-replacement-scan-{}.log", std::process::id()));
|
||||||
|
let stale =
|
||||||
|
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle\n";
|
||||||
|
fs::write(&path, stale)?;
|
||||||
|
let offset = log_len(&path)?;
|
||||||
|
assert!(!live_disk_loss_scan_completed_from_path(&path, offset, target)?);
|
||||||
|
let fresh =
|
||||||
|
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle\n";
|
||||||
|
fs::write(&path, format!("{stale}{fresh}"))?;
|
||||||
|
assert!(live_disk_loss_scan_completed_from_path(&path, offset, target)?);
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_requires_definitive_status_and_prior_census_match() {
|
||||||
|
let target = Path::new("/mnt/target");
|
||||||
|
let non_definitive = serde_json::json!({
|
||||||
|
"cluster": {"definitive": false, "records": [{"state": "completed", "targetSlots": ["/mnt/target"]}]}
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
replacement_completion_state(&non_definitive, target, BTreeSet::new()).unwrap(),
|
||||||
|
CompletionSample::Pending
|
||||||
|
);
|
||||||
|
|
||||||
|
let omitted = serde_json::json!({
|
||||||
|
"cluster": {"records": [{"state": "completed", "targetSlots": ["/mnt/target"]}]}
|
||||||
|
});
|
||||||
|
assert!(replacement_completion_state(&omitted, target, BTreeSet::new()).is_err());
|
||||||
|
|
||||||
|
let definitive = serde_json::json!({
|
||||||
|
"cluster": {"definitive": true, "records": [{"state": "completed", "targetSlots": ["/mnt/target"]}]}
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
replacement_completion_state(&definitive, target, BTreeSet::from(["missing".to_string()])).unwrap(),
|
||||||
|
CompletionSample::CompletedWithIncomplete(BTreeSet::from(["missing".to_string()]))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
replacement_completion_state(&definitive, target, BTreeSet::new()).unwrap(),
|
||||||
|
CompletionSample::Ready
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn completion_poll_samples_census_before_status() {
|
||||||
|
let order = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||||
|
let census_order = order.clone();
|
||||||
|
let status_order = order.clone();
|
||||||
|
let sample = sample_replacement_completion(
|
||||||
|
Path::new("/mnt/target"),
|
||||||
|
move || {
|
||||||
|
census_order.borrow_mut().push("census");
|
||||||
|
Ok::<_, Box<dyn Error + Send + Sync>>(BTreeSet::new())
|
||||||
|
},
|
||||||
|
move || async move {
|
||||||
|
status_order.borrow_mut().push("status");
|
||||||
|
Ok::<_, Box<dyn Error + Send + Sync>>(serde_json::json!({
|
||||||
|
"cluster": {"definitive": true, "records": [{"state": "completed", "targetSlots": ["/mnt/target"]}]}
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(sample, CompletionSample::Ready);
|
||||||
|
assert_eq!(*order.borrow(), ["census", "status"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn completed_status_confirms_a_stale_incomplete_census() {
|
||||||
|
let samples = std::rc::Rc::new(std::cell::RefCell::new(std::collections::VecDeque::from([
|
||||||
|
BTreeSet::from(["missing".to_string()]),
|
||||||
|
BTreeSet::new(),
|
||||||
|
])));
|
||||||
|
let census_samples = samples.clone();
|
||||||
|
let result = confirm_replacement_completion(
|
||||||
|
Path::new("/mnt/target"),
|
||||||
|
move || {
|
||||||
|
census_samples
|
||||||
|
.borrow_mut()
|
||||||
|
.pop_front()
|
||||||
|
.ok_or_else(|| "missing census sample".into())
|
||||||
|
},
|
||||||
|
|| async {
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"cluster": {"definitive": true, "records": [{"state": "completed", "targetSlots": ["/mnt/target"]}]}
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, CompletionSample::Ready);
|
||||||
|
assert!(samples.borrow().is_empty());
|
||||||
|
|
||||||
|
let status_samples = std::rc::Rc::new(std::cell::RefCell::new(std::collections::VecDeque::from([
|
||||||
|
serde_json::json!({
|
||||||
|
"cluster": {"definitive": true, "records": [{"state": "completed", "targetSlots": ["/mnt/target"]}]}
|
||||||
|
}),
|
||||||
|
serde_json::json!({
|
||||||
|
"cluster": {"definitive": false, "records": []}
|
||||||
|
}),
|
||||||
|
])));
|
||||||
|
let persistent = std::rc::Rc::new(std::cell::RefCell::new(std::collections::VecDeque::from([
|
||||||
|
BTreeSet::from(["missing".to_string()]),
|
||||||
|
BTreeSet::from(["still-missing".to_string()]),
|
||||||
|
])));
|
||||||
|
let census_samples = persistent.clone();
|
||||||
|
let statuses = status_samples.clone();
|
||||||
|
let result = confirm_replacement_completion(
|
||||||
|
Path::new("/mnt/target"),
|
||||||
|
move || {
|
||||||
|
census_samples
|
||||||
|
.borrow_mut()
|
||||||
|
.pop_front()
|
||||||
|
.ok_or_else(|| "missing census sample".into())
|
||||||
|
},
|
||||||
|
move || {
|
||||||
|
let statuses = statuses.clone();
|
||||||
|
async move {
|
||||||
|
statuses
|
||||||
|
.borrow_mut()
|
||||||
|
.pop_front()
|
||||||
|
.ok_or_else(|| "missing status sample".into())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
CompletionSample::CompletedWithIncomplete(BTreeSet::from(["still-missing".to_string()]))
|
||||||
|
);
|
||||||
|
assert!(persistent.borrow().is_empty());
|
||||||
|
assert_eq!(status_samples.borrow().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_status_requires_definitive_empty_records() {
|
||||||
|
let target = Path::new("/mnt/target");
|
||||||
|
let non_definitive = serde_json::json!({"cluster": {"definitive": false, "records": []}});
|
||||||
|
assert!(assert_no_replacement_status_records_in_status(&non_definitive, target).is_err());
|
||||||
|
let omitted = serde_json::json!({"cluster": {"records": []}});
|
||||||
|
assert!(assert_no_replacement_status_records_in_status(&omitted, target).is_err());
|
||||||
|
let definitive = serde_json::json!({"cluster": {"definitive": true, "records": []}});
|
||||||
|
assert!(assert_no_replacement_status_records_in_status(&definitive, target).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
/// Linux mount namespaces are per-thread; keep mount setup and process
|
/// Linux mount namespaces are per-thread; keep mount setup and process
|
||||||
/// spawning on one OS thread so child RustFS nodes inherit the test mounts.
|
/// spawning on one OS thread so child RustFS nodes inherit the test mounts.
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
|||||||
Reference in New Issue
Block a user