Compare commits

...

19 Commits

Author SHA1 Message Date
houseme 600d037b25 test(heal): cover MRF idle checkpoint cleanup (#7497)
Add a runtime cleanup regression test that publishes both retained replay and runtime committed checkpoints, writes scoped and legacy journals, and verifies idle cleanup removes every recovery anchor from the registered local disks.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 21:00:18 +08:00
houseme 3fa2b334be test(scanner): require two-hour measured ABBA windows (#7493)
Reject measured Scanner/Heal release ABBA manifests and summaries whose evidence window is shorter than the W21 two-hour release requirement.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:48:33 +08:00
houseme 1b549d5907 test(scanner): require G14 same-window field coverage (#7489)
Tighten the Scanner/Heal release bundle checker so G14 same-window evidence must name the EC8+4, multi-set, and multi-pool fields covered in that measurement window.

Keep the release gate blocked when same-window evidence omits one of the required G14 fields, without changing production runtime behavior.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:48:24 +08:00
houseme 11c4ce96eb test(scanner): reject empty release evidence artifacts (#7496)
Require Scanner/Heal release bundle artifact paths to resolve to non-empty files before hashing them.

Cover empty hard-gate artifacts in the existing release bundle checker self-test and keep profile artifact size checking on the shared artifact boundary.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:42:31 +08:00
houseme 4508a0985d test(scanner): profile EC84 evidence case runs (#7495)
Give Scanner/Heal evidence cases explicit runtime profiles so EC8+4 background restart and crash cases use their own object count, object size, and partial-progress timeout defaults instead of inheriting the legacy 4x1 case assumptions.

Expose the runtime profile in plan-only output and cover every registry case in the script self-test.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:42:22 +08:00
houseme ed1b9f25d6 test(scanner): require MRF replay bundle fields (#7486)
Bind Scanner/Heal release bundle evidence for MRF durable replay to replay counts, retained responsibility anchors, and successor snapshot publication evidence.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:41:56 +08:00
houseme 274c2bf402 fix(e2e): group scanner heal evidence payload (#7494)
Group the EC8+4 Scanner/Heal evidence writer inputs into a typed payload so the distributed e2e crate stays within the clippy argument limit without weakening the lint.

The evidence writer still validates the same S3 bodies, physical shard census, process restart PIDs, and node listings.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:41:31 +08:00
houseme 9aebcefa9c test(scanner): harden measured ABBA evidence claims (#7491)
Reject measured Scanner/Heal ABBA manifests whose mixed-version evidence uses the same baseline and candidate source revision or binary hash.

Require crash fault modes and profile artifact names to match the exact supported sets, rejecting missing, duplicate, and unknown values.

Update harness fixtures and regression coverage for same-build mixed-version claims and exact-set release evidence fields.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:32:52 +08:00
houseme 1748814bbf test(scanner): bind profile artifacts in release evidence (#7487)
Require Scanner/Heal release bundles to attach every required profiling artifact to P1 profile evidence with relative paths, artifact formats, non-empty files, hashes, and optional per-artifact measurement-window checks.

Document the tightened release bundle profile contract and cover missing, tampered, and mismatched-window profile artifact regressions in the existing checker self-test.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:32:37 +08:00
houseme ee5f76c180 fix(heal): publish committed MRF runtime checkpoints (#7490)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:32:22 +08:00
houseme 4d68c32b75 test(scanner): require mixed-version evidence roles (#7485)
Tighten the Scanner/Heal release bundle checker so mixed-version, scoped-ACK, and rollback fields cannot reuse a generic versions list without proving the expected evidence role.

Require version lists to use source revision identities and include the tested source revision. Also require profile evidence fields to name the core profiling artifacts before release approval.

The release gate remains blocked until measured field evidence is present.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:07:11 +08:00
houseme 8c6689ff13 test(scanner): derive evidence runner profile from registry (#7484)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 19:27:23 +08:00
houseme 595f9f662d test(ecstore): bind MRF manifest CAS dirsync recovery (#7482)
Cover the committed MRF manifest path through LocalDisk conditional CAS when the metadata directory fsync fails. The fixture proves the previous manifest anchor survives rollback, an unanchored first successor is removed, and the legacy MRF journal remains readable even while global durability is relaxed.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 18:53:58 +08:00
houseme 4a2b15cb82 fix(heal): harden MRF replay boundaries (#7483)
Reject journal records with unknown version-presence flags even when their CRC is valid, so rollback/future payloads cannot be accepted as known records.

Gate committed checkpoint cleanup by the writer owner captured from the replay source, preserving retained manifests from other owners inside the same sequence window.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 18:53:43 +08:00
houseme 2ba7f95547 test(heal): write EC84 distributed restart oracle (#7481)
Bind the distributed EC8+4 restart evidence lane to a scanner/heal oracle artifact so release validation can consume the real nextest run instead of accepting only a passing test.

Require the registry to assert 8+4 erasure geometry for the three-node, four-drive case.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 18:15:46 +08:00
houseme 7b3dad6bae test(heal): cover MRF snapshot torn successor recovery
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-08 17:28:09 +08:00
houseme c0754f5b1c test(heal): preserve EC84 restart semantics (#7478)
Keep the EC8+4 background restart lane on the graceful-stop path and assert clean-restart marker absence only for restart scenarios. This prevents the hard evidence gate from silently exercising the crash path when it claims restart coverage.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 17:26:11 +08:00
houseme 5dc0e3b402 heal: preserve merged result for duplicate submits
Keep same-request-id replay receipts accepted for the receipt API, but preserve the legacy submit_heal_request duplicate admission result as Merged.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-08 16:59:58 +08:00
houseme cc5487e7de heal: verify admin recreate pool metadata (#7474)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 16:57:13 +08:00
22 changed files with 1433 additions and 129 deletions
+2
View File
@@ -43,6 +43,8 @@
"min_objects": 5,
"max_objects": 5,
"topology": {"nodes": 3, "drives_per_node": 4},
"erasure": {"data_blocks": 8, "parity_blocks": 4},
"erasure_set_drive_count": 12,
"scope": "3-node x 4-drive single-set EC8+4, graceful target restart, preformatted replacement drive, exact unversioned S3 bodies and physical target shards; not mixed-version, multi-pool or long-window ABBA."
},
"background-target-restart-ec8-4": {
+185 -1
View File
@@ -12,12 +12,18 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, assert_inventory, payload_for, put_object, unique_bucket, wait_until};
use super::harness::{
DistCluster, DistLayout, TestResult, assert_inventory, get_object_bytes, payload_for, put_object, sha256_hex, unique_bucket,
wait_until,
};
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
use crate::common::init_logging;
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -25,6 +31,8 @@ const EC84_NODE_COUNT: usize = 3;
const EC84_DRIVES_PER_NODE: usize = 4;
const EC84_DATA_BLOCKS: usize = 8;
const EC84_PARITY_BLOCKS: usize = 4;
const EC84_TARGET_DRIVE_RESTART_CASE: &str = "ec84-target-drive-restart";
const EC84_TARGET_DRIVE_RESTART_ORACLE: &str = "ec84-target-drive-restart.json";
#[derive(Clone)]
struct ExpectedShard {
@@ -33,6 +41,91 @@ struct ExpectedShard {
baseline: VersionShardCensus,
}
struct ScannerHealEvidenceContext {
directory: PathBuf,
run: Value,
}
struct ScannerHealEvidencePayload<'a> {
dist: &'a DistCluster,
bucket: &'a str,
expected: &'a [ExpectedShard],
outage_key: &'a str,
outage_body: &'a [u8],
replaced_drive: &'a Path,
pid_before: u32,
pid_after: u32,
node_listings: Vec<Vec<String>>,
}
fn file_sha256(path: &Path) -> TestResult<String> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
fn compiled_test_identity() -> Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
fn string_field<'a>(value: &'a Value, path: &str) -> TestResult<&'a str> {
let mut current = value;
for segment in path.split('.') {
current = current
.get(segment)
.ok_or_else(|| format!("scanner/heal run receipt missing {path}"))?;
}
current
.as_str()
.filter(|text| !text.is_empty())
.ok_or_else(|| format!("scanner/heal run receipt has invalid {path}").into())
}
fn scanner_heal_evidence_context() -> TestResult<Option<ScannerHealEvidenceContext>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: Value = serde_json::from_slice(&std::fs::read(receipt)?)?;
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
if built[key] != run["test_build"][key] {
return Err(format!("compiled test identity differs for {key}").into());
}
}
let binary_path = PathBuf::from(string_field(&run, "binary.path")?);
if file_sha256(&binary_path)? != string_field(&run, "binary.sha256")? {
return Err("server binary must match the run receipt".into());
}
if file_sha256(&std::env::current_exe()?)? != string_field(&run, "test_binary.sha256")? {
return Err("test executable must match the run receipt".into());
}
if directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(ScannerHealEvidenceContext { directory, run }))
}
fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
if census.data_blocks != Some(EC84_DATA_BLOCKS) || census.parity_blocks != Some(EC84_PARITY_BLOCKS) {
return Err(format!("object {key} did not use EC8+4 geometry: {census:?}").into());
@@ -49,6 +142,65 @@ fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
Ok(())
}
async fn write_scanner_heal_evidence(context: ScannerHealEvidenceContext, payload: ScannerHealEvidencePayload<'_>) -> TestResult {
let verifier = payload.dist.client(0)?;
let mut objects = Vec::new();
for item in payload.expected {
let actual = get_object_bytes(&verifier, payload.bucket, &item.key).await?;
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, &item.key, None)?;
objects.push(serde_json::json!({
"key": item.key,
"version_id": null,
"expected_bytes": item.body.len(),
"actual_bytes": actual.len(),
"expected_sha256": sha256_hex(&item.body),
"actual_sha256": sha256_hex(&actual),
"expected_physical": item.baseline,
"physical": physical,
}));
}
let actual = get_object_bytes(&verifier, payload.bucket, payload.outage_key).await?;
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, payload.outage_key, None)?;
objects.push(serde_json::json!({
"key": payload.outage_key,
"version_id": null,
"expected_bytes": payload.outage_body.len(),
"actual_bytes": actual.len(),
"expected_sha256": sha256_hex(payload.outage_body),
"actual_sha256": sha256_hex(&actual),
"expected_physical": null,
"physical": physical,
}));
let evidence = serde_json::json!({
"schema": 1,
"case": EC84_TARGET_DRIVE_RESTART_CASE,
"evidence": "process-restart",
"run_id": string_field(&context.run, "run_id")?,
"source_revision": string_field(&context.run, "source_revision")?,
"test_build": compiled_test_identity(),
"binary_sha256": string_field(&context.run, "binary.sha256")?,
"test_binary_sha256": string_field(&context.run, "test_binary.sha256")?,
"topology": {"nodes": EC84_NODE_COUNT, "drives_per_node": EC84_DRIVES_PER_NODE},
"pid_before": payload.pid_before,
"pid_after": payload.pid_after,
"unclean_shutdown_marker": false,
"objects": objects,
"node_listings": payload.node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(context.directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE))?;
output.write_all(&data)?;
output.sync_all()?;
Ok(())
}
fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> TestResult {
for key in keys {
let census = census_object_version_on_disk(drive, bucket, key, None)?;
@@ -87,6 +239,7 @@ async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<Ex
#[tokio::test]
async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart() -> TestResult {
init_logging();
let evidence_context = scanner_heal_evidence_context()?;
let mut dist = DistCluster::start_with_env(
DistLayout::ThreeByFourEc84,
&[
@@ -116,6 +269,11 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
let format_path = replaced_drive.join(".rustfs.sys").join("format.json");
let format_json = std::fs::read(&format_path)?;
let target_pid_before = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("target process is absent before graceful restart")?
.id();
dist.cluster.stop_node_gracefully(replaced_node).await?;
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
std::fs::rename(&replaced_drive, &retired_drive)?;
@@ -138,6 +296,11 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
.await?;
dist.cluster.start_node(replaced_node).await?;
let target_pid_after = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("target process is absent after restart")?
.id();
let heal_body =
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[0].url);
@@ -167,6 +330,7 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
.chain(std::iter::once((outage_key.to_string(), outage_body.clone())))
.collect::<BTreeMap<_, _>>();
let expected_keys = inventory.keys().cloned().collect::<HashSet<_>>();
let mut node_listings = Vec::new();
for node_index in 0..dist.cluster.nodes.len() {
let client = dist.client(node_index)?;
assert_inventory(&client, &bucket, &inventory).await?;
@@ -177,6 +341,26 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
.filter_map(|object| object.key().map(str::to_owned))
.collect::<HashSet<_>>();
assert_eq!(observed, expected_keys, "node {node_index} listing diverged after EC8+4 heal");
let mut observed = observed.into_iter().collect::<Vec<_>>();
observed.sort();
node_listings.push(observed);
}
if let Some(context) = evidence_context {
write_scanner_heal_evidence(
context,
ScannerHealEvidencePayload {
dist: &dist,
bucket: &bucket,
expected: &expected,
outage_key,
outage_body: &outage_body,
replaced_drive: &replaced_drive,
pid_before: target_pid_before,
pid_after: target_pid_after,
node_listings,
},
)
.await?;
}
Ok(())
@@ -1567,7 +1567,10 @@ mod tests {
"Restored target endpoint forwarding"
);
} else {
if scenario == InterruptionScenario::BackgroundTargetRestart {
if matches!(
scenario,
InterruptionScenario::BackgroundTargetRestart | InterruptionScenario::BackgroundTargetRestartEc84
) {
cluster.stop_node_gracefully(interruption_node).await?;
} else {
cluster.stop_node(interruption_node)?;
@@ -1589,7 +1592,10 @@ mod tests {
if background_enabled {
let marker_exists = unclean_shutdown_marker.is_file();
unclean_shutdown_marker_observed = Some(marker_exists);
let expected_marker = !matches!(scenario, InterruptionScenario::BackgroundTargetRestart);
let expected_marker = matches!(
scenario,
InterruptionScenario::BackgroundTargetCrash | InterruptionScenario::BackgroundTargetCrashEc84
);
assert!(
marker_exists == expected_marker,
"background restart/crash lane observed unexpected unclean-shutdown marker state"
+80
View File
@@ -22463,6 +22463,86 @@ mod test {
);
}
#[cfg(unix)]
#[tokio::test]
async fn conditional_mrf_manifest_dir_fsync_failure_keeps_recovery_anchors() {
use tempfile::tempdir;
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
const MRF_COMMIT_MANIFEST_SLOT_1: &str = ".heal-mrf-commit.1.bin";
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
let dir = tempdir().expect("temp dir should be created");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let previous_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
let successor_manifest = Bytes::from_static(b"mrf-committed-manifest-v2");
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
assert_eq!(
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(previous_manifest.clone()),)
.await
.expect("previous MRF manifest should commit"),
ConditionalFileUpdate::Updated
);
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
.await
.expect("legacy MRF journal should be retained");
let manifest_path = disk
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.expect("MRF manifest path should resolve");
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
assert!(
os::fsync_dir_recorder::was_fsynced(parent),
"system metadata MRF manifest publication must fsync the metadata directory even under relaxed durability"
);
os::fsync_dir_recorder::set_failure(parent, ErrorKind::Other);
let err = disk
.compare_and_update_file(
RUSTFS_META_BUCKET,
MRF_COMMIT_MANIFEST_SLOT_0,
Some(previous_manifest.clone()),
Some(successor_manifest),
)
.await
.expect_err("directory fsync failure must fail the MRF manifest successor commit");
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::Other));
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.await
.expect("previous committed MRF manifest should remain readable after rollback"),
previous_manifest
);
os::fsync_dir_recorder::set_failure(parent, ErrorKind::Other);
let err = disk
.compare_and_update_file(
RUSTFS_META_BUCKET,
MRF_COMMIT_MANIFEST_SLOT_1,
None,
Some(Bytes::from_static(b"first-successor-manifest")),
)
.await
.expect_err("directory fsync failure must fail first MRF manifest commit");
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::Other));
assert!(
matches!(
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_1).await,
Err(DiskError::FileNotFound)
),
"uncommitted first MRF manifest must be removed when no committed anchor exists"
);
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
.await
.expect("legacy MRF journal should remain readable after failed manifest publication"),
legacy_journal
);
}
#[cfg(unix)]
#[tokio::test]
async fn conditional_file_update_dir_fsync_failure_removes_new_file_without_anchor() {
+81 -10
View File
@@ -113,6 +113,7 @@ pub struct ErasureSetHealer {
heal_opts: HealOpts,
source: HealRequestSource,
target_endpoints: Arc<[String]>,
pool_metadata_target_endpoints: Arc<[String]>,
replacement_task_id: Option<String>,
replacement_target_identities: Option<Arc<[ReplacementTargetIdentity]>>,
mainline_pacer: Option<Arc<super::pacing::MainlinePacer>>,
@@ -362,6 +363,7 @@ impl ErasureSetHealer {
heal_opts,
source,
target_endpoints: Vec::new().into(),
pool_metadata_target_endpoints: Vec::new().into(),
replacement_task_id: None,
replacement_target_identities: None,
mainline_pacer: None,
@@ -385,6 +387,13 @@ impl ErasureSetHealer {
self
}
pub(crate) fn with_pool_metadata_targets(mut self, mut target_endpoints: Vec<String>) -> Self {
target_endpoints.sort_unstable();
target_endpoints.dedup();
self.pool_metadata_target_endpoints = target_endpoints.into();
self
}
pub(crate) fn with_replacement_identity_fence(
mut self,
replacement_target_identities: Option<Vec<ReplacementTargetIdentity>>,
@@ -948,10 +957,16 @@ impl ErasureSetHealer {
resume_manager: &ResumeManager,
checkpoint_manager: &CheckpointManager,
) -> Result<()> {
if self.replacement_task_id.is_none() {
let target_endpoints = if self.pool_metadata_target_endpoints.is_empty() {
self.target_endpoints.as_ref()
} else {
self.pool_metadata_target_endpoints.as_ref()
};
let target_scoped_recreate = !self.heal_opts.dry_run && self.heal_opts.recreate && !target_endpoints.is_empty();
if self.replacement_task_id.is_none() && !target_scoped_recreate {
return Ok(());
}
if self.target_endpoints.is_empty() {
if target_endpoints.is_empty() {
return Err(Error::TaskExecutionFailed {
message: "Replacement pool metadata heal requires target endpoints".to_string(),
});
@@ -978,17 +993,11 @@ impl ErasureSetHealer {
.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts)
.await
{
Ok((result, None)) if target_outcomes_complete(&result, &self.target_endpoints) => {
Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => {
let object_size = result_object_size_u64(&result);
match self
.storage
.replacement_targets_have_version(
RUSTFS_META_BUCKET,
POOL_META_NAME,
None,
&self.heal_opts,
&self.target_endpoints,
)
.replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts, target_endpoints)
.await
{
Ok(true) => (object_size, Ok(())),
@@ -2762,6 +2771,68 @@ mod resume_loop_tests {
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
}
#[tokio::test]
async fn admin_recreate_target_heals_pool_metadata_before_completion() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts {
recreate: true,
pool: Some(0),
set: Some(0),
..Default::default()
},
HealRequestSource::Admin,
)
.with_pool_metadata_targets(vec!["replacement-a".to_string()]);
env.storage
.set_result(POOL_META_NAME, None, replacement_target_ok_result("replacement-a", POOL_META_NAME));
healer
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect("admin recreate should heal and verify pool metadata");
assert!(env.resume.get_state().await.completed);
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
}
#[tokio::test]
async fn admin_recreate_pool_metadata_readback_failure_keeps_resume_state() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts {
recreate: true,
pool: Some(0),
set: Some(0),
..Default::default()
},
HealRequestSource::Admin,
)
.with_pool_metadata_targets(vec!["replacement-a".to_string()]);
env.storage
.set_result(POOL_META_NAME, None, replacement_target_ok_result("replacement-a", POOL_META_NAME));
env.storage.set_replacement_commit_evidence(POOL_META_NAME, None, false);
let error = healer
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect_err("unconfirmed admin recreate pool metadata must keep the set incomplete");
assert!(error.to_string().contains("Erasure set heal incomplete"));
let state = env.resume.get_state().await;
assert!(!state.completed);
assert_eq!(state.retry_count, 1);
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
}
#[tokio::test]
async fn retry_exhaustion_keeps_resume_artifacts_for_recovery() {
let env = make_env().await;
+11 -3
View File
@@ -1527,7 +1527,7 @@ impl HealManager {
request: HealRequest,
preserve_alias: bool,
) -> Result<HealAdmissionReceipt> {
self.submit_heal_request_with_receipt_alias_and_mrf_notice(request, preserve_alias, None)
self.submit_heal_request_with_receipt_alias_and_mrf_notice(request, preserve_alias, true, None)
.await
}
@@ -1563,7 +1563,7 @@ impl HealManager {
request: HealRequest,
mrf_notice_target: MrfRepairNoticeTarget,
) -> Result<HealAdmissionReceipt> {
self.submit_heal_request_with_receipt_alias_and_mrf_notice(request, true, Some(mrf_notice_target))
self.submit_heal_request_with_receipt_alias_and_mrf_notice(request, true, true, Some(mrf_notice_target))
.await
}
@@ -1583,6 +1583,7 @@ impl HealManager {
&self,
request: HealRequest,
preserve_alias: bool,
accept_same_request_id_replay: bool,
mrf_notice_target: Option<MrfRepairNoticeTarget>,
) -> Result<HealAdmissionReceipt> {
let admission_start = Instant::now();
@@ -1661,7 +1662,11 @@ impl HealManager {
});
if let Some((matches_existing, duplicate_state)) = request_id_admission {
let admission = if matches_existing {
if accept_same_request_id_replay {
HealAdmissionResult::Accepted
} else {
Self::duplicate_admission_for_request(&request, &config)
}
} else {
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
};
@@ -1908,7 +1913,10 @@ impl HealManager {
/// Submit heal request.
pub async fn submit_heal_request(&self, request: HealRequest) -> Result<HealAdmissionResult> {
Ok(self.submit_heal_request_with_receipt_and_alias(request, true).await?.result)
Ok(self
.submit_heal_request_with_receipt_alias_and_mrf_notice(request, true, false, None)
.await?
.result)
}
/// Get task status
+240 -11
View File
@@ -294,7 +294,11 @@ fn decode_one(data: &[u8]) -> Option<(MrfIntent, usize)> {
};
let attempts = data[3];
let enqueued_at_ms = u64::from_le_bytes(data[4..12].try_into().ok()?);
let has_version = data[12] != 0;
let has_version = match data[12] {
0 => false,
1 => true,
_ => return None,
};
let mut cursor = MRF_RECORD_FIXED_HEAD;
let version_id = if has_version {
if data.len() < cursor + 16 {
@@ -517,6 +521,8 @@ async fn submit_mrf_heal_request(manager: &HealManager, intent: &MrfIntent) -> c
struct MrfRuntime {
queue: MrfQueue,
config: MrfConsumerConfig,
checkpoint_owner: Uuid,
next_checkpoint_sequence: u64,
new_since_flush: usize,
/// True while the in-memory pending set has changed since the last
/// journal flush (push, pop, or an attempts bump that alters the encoded
@@ -533,6 +539,12 @@ struct MrfRuntime {
/// Partial-write responsibilities accepted from replay and waiting for an
/// exact storage-owned proof before the startup journal can be deleted.
durable_replay_anchors: Vec<MrfDurableRepairAnchor>,
/// Startup replay source to remove after the retained replay
/// responsibilities are discharged. `None` means the runtime only needs
/// the legacy journal cleanup path for snapshots it wrote itself.
replay_cleanup: Option<ReplayCleanup>,
/// Last committed checkpoint published by this runtime flush path.
runtime_checkpoint: Option<(Uuid, u64)>,
/// Earliest instant a full-admission retry may proceed.
backoff_until: Option<tokio::time::Instant>,
}
@@ -556,6 +568,34 @@ impl MrfRuntime {
async fn flush(&mut self) {
let (authoritative, legacy) = self.snapshot();
let (committed_persisted, committed_on_disk) = if authoritative.is_empty() {
(true, false)
} else {
match snapshot::publish_committed_snapshot(
&journal_disks().await,
self.checkpoint_owner,
self.next_checkpoint_sequence,
&authoritative,
self.config.journal_max_bytes,
)
.await
{
Ok(publication) => {
self.runtime_checkpoint = Some((publication.owner, publication.sequence));
self.next_checkpoint_sequence = publication.sequence.saturating_add(1);
(true, true)
}
Err(err) => {
tracing::warn!(
target: "rustfs::heal::mrf",
error = %err,
sequence = self.next_checkpoint_sequence,
"MRF committed checkpoint publish failed; retaining previous replay anchor"
);
(false, false)
}
}
};
let authoritative_persisted = write_journal(MRF_SCOPED_JOURNAL_PATH, &authoritative).await;
if !authoritative.is_empty() {
counter!("rustfs_heal_mrf_journal_fsync_total").increment(1);
@@ -566,10 +606,10 @@ impl MrfRuntime {
// old reader from observing a newer epoch that a new reader cannot
// see when the canonical write is unavailable.
let legacy_persisted = authoritative_persisted && write_journal(MRF_JOURNAL_PATH, &legacy).await;
// Keep dirty until both the authoritative snapshot and its
// compatibility mirror have been accepted; otherwise a one-sided
// failure would never retry the missing file.
let persisted = authoritative_persisted && legacy_persisted;
// Keep dirty until the committed checkpoint, authoritative snapshot,
// and compatibility mirror have all been accepted; otherwise a
// one-sided failure would never retry the missing recovery anchor.
let persisted = committed_persisted && authoritative_persisted && legacy_persisted;
self.new_since_flush = 0;
// Keep the dirty flag when every disk write failed: a clean backlog
// would otherwise never rewrite, losing the periodic persist retry a
@@ -577,7 +617,7 @@ impl MrfRuntime {
if persisted {
self.dirty = false;
}
self.journal_on_disk |= authoritative_persisted || legacy_persisted;
self.journal_on_disk |= committed_on_disk || authoritative_persisted || legacy_persisted;
}
/// Drain pending intents into the heal manager until it is full, the
@@ -635,6 +675,45 @@ impl MrfRuntime {
self.retain_replay_journal || !self.durable_replay_anchors.is_empty()
}
fn replay_cleanup_to_delete(&self) -> Option<ReplayCleanup> {
if self.journal_on_disk && !self.retained_replay_journal() {
Some(self.replay_cleanup.unwrap_or(ReplayCleanup::Legacy))
} else {
None
}
}
async fn delete_idle_recovery_anchors(&mut self) -> bool {
let runtime_deleted = match self.runtime_checkpoint {
Some((owner, sequence)) => {
match snapshot::delete_committed_snapshots_through(owner, sequence, self.config.journal_max_bytes).await {
Ok(deleted) => deleted,
Err(err) => {
tracing::warn!(
target: "rustfs::heal::mrf",
error = %err,
sequence,
"MRF runtime checkpoint cleanup failed"
);
false
}
}
}
None => true,
};
let replay_deleted = match self.replay_cleanup_to_delete() {
Some(cleanup) => delete_replay_source(cleanup, self.config.journal_max_bytes).await,
None => true,
};
if runtime_deleted && replay_deleted {
self.runtime_checkpoint = None;
self.replay_cleanup = None;
true
} else {
false
}
}
fn discharge_durable_replay_anchors(&mut self) {
if self.durable_replay_anchors.is_empty() {
return;
@@ -704,6 +783,8 @@ struct ReplayOutcome {
journal_on_disk: bool,
retain_journal_for_replay: bool,
durable_replay_anchors: Vec<MrfDurableRepairAnchor>,
cleanup: Option<ReplayCleanup>,
next_checkpoint_sequence: u64,
}
fn replay_must_retain_journal(
@@ -715,10 +796,10 @@ fn replay_must_retain_journal(
rearm_incomplete || pending_depth > 0 || accepted_without_durable_anchor || durable_replay_anchors > 0
}
#[derive(Clone, Copy)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ReplayCleanup {
Legacy,
Committed { sequence: u64 },
Committed { owner: Uuid, sequence: u64 },
}
struct ReplaySource {
@@ -731,6 +812,7 @@ async fn read_replay_source(max_bytes: usize) -> Result<Option<ReplaySource>, sn
return Ok(Some(ReplaySource {
data: committed.payload().to_vec(),
cleanup: ReplayCleanup::Committed {
owner: committed.owner(),
sequence: committed.sequence(),
},
}));
@@ -754,7 +836,8 @@ async fn read_replay_source(max_bytes: usize) -> Result<Option<ReplaySource>, sn
async fn delete_replay_source(cleanup: ReplayCleanup, max_bytes: usize) -> bool {
let committed_deleted = match cleanup {
ReplayCleanup::Legacy => true,
ReplayCleanup::Committed { sequence } => match snapshot::delete_committed_snapshots_through(sequence, max_bytes).await {
ReplayCleanup::Committed { owner, sequence } => {
match snapshot::delete_committed_snapshots_through(owner, sequence, max_bytes).await {
Ok(deleted) => deleted,
Err(err) => {
tracing::warn!(
@@ -765,7 +848,8 @@ async fn delete_replay_source(cleanup: ReplayCleanup, max_bytes: usize) -> bool
);
false
}
},
}
}
};
committed_deleted && delete_journals().await
}
@@ -786,6 +870,8 @@ async fn replay_into(
journal_on_disk: false,
retain_journal_for_replay: false,
durable_replay_anchors: Vec::new(),
cleanup: None,
next_checkpoint_sequence: 1,
};
}
Err(err) => {
@@ -799,10 +885,16 @@ async fn replay_into(
journal_on_disk: true,
retain_journal_for_replay: true,
durable_replay_anchors: Vec::new(),
cleanup: None,
next_checkpoint_sequence: 1,
};
}
};
let cleanup = source.cleanup;
let next_checkpoint_sequence = match cleanup {
ReplayCleanup::Legacy => 1,
ReplayCleanup::Committed { sequence, .. } => sequence.saturating_add(1),
};
let data = source.data;
let (decoded, truncated) = decode_journal(&data);
let replayed = decoded.len();
@@ -906,6 +998,8 @@ async fn replay_into(
journal_on_disk,
retain_journal_for_replay,
durable_replay_anchors,
cleanup: journal_on_disk.then_some(cleanup),
next_checkpoint_sequence,
}
}
@@ -916,11 +1010,15 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
let mut runtime = MrfRuntime {
queue: MrfQueue::new(config.queue_capacity, config.journal_max_bytes),
config: config.clone(),
checkpoint_owner: Uuid::new_v4(),
next_checkpoint_sequence: 1,
new_since_flush: 0,
dirty: false,
journal_on_disk: false,
retain_replay_journal: false,
durable_replay_anchors: Vec::new(),
replay_cleanup: None,
runtime_checkpoint: None,
backoff_until: None,
};
@@ -930,6 +1028,8 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
runtime.journal_on_disk = replay.journal_on_disk;
runtime.retain_replay_journal = replay.retain_journal_for_replay;
runtime.durable_replay_anchors = replay.durable_replay_anchors;
runtime.replay_cleanup = replay.cleanup;
runtime.next_checkpoint_sequence = replay.next_checkpoint_sequence;
// Anything still pending (e.g. the manager was full and backoff armed)
// must be re-persisted by the next flush before replay can delete the
// startup anchor.
@@ -994,7 +1094,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
TickAction::DeleteJournal => {
// All replayed intents have either been accepted,
// merged, or replaced by a pending successor snapshot.
if delete_journals().await {
if runtime.delete_idle_recovery_anchors().await {
runtime.journal_on_disk = false;
gauge!("rustfs_heal_mrf_journal_bytes").set(0.0);
}
@@ -1038,6 +1138,7 @@ fn tick_action(dirty: bool, depth: usize, journal_on_disk: bool, retain_replay_j
mod tests {
use super::*;
use rustfs_common::mrf_channel::{MrfIntent, MrfKind, MrfVerifiedRepairDisposition, MrfVerifiedRepairEvent};
use serial_test::serial;
use std::sync::Arc as StdArc;
fn intent(bucket: &str, object: &str, attempts: u8) -> MrfIntent {
@@ -1053,6 +1154,12 @@ mod tests {
}
}
fn encoded_payload(intent: &MrfIntent) -> Vec<u8> {
let mut payload = Vec::new();
assert!(encode_intent(intent, &mut payload), "fixture intent must encode");
payload
}
#[test]
fn tick_action_table() {
use TickAction::*;
@@ -1110,14 +1217,23 @@ mod tests {
let bucket_incarnation_id = uuid::Uuid::new_v4();
let anchor = rustfs_common::mrf_channel::MrfDurableRepairAnchor::from_intent(&intent, bucket_incarnation_id)
.expect("fresh replay lease and bucket incarnation build a durable anchor");
let cleanup_owner = uuid::Uuid::new_v4();
let cleanup = ReplayCleanup::Committed {
owner: cleanup_owner,
sequence: 17,
};
let mut runtime = MrfRuntime {
queue: MrfQueue::new(2, usize::MAX),
config: MrfConsumerConfig::default(),
checkpoint_owner: Uuid::new_v4(),
next_checkpoint_sequence: 1,
new_since_flush: 0,
dirty: false,
journal_on_disk: true,
retain_replay_journal: false,
durable_replay_anchors: vec![anchor],
replay_cleanup: Some(cleanup),
runtime_checkpoint: None,
backoff_until: None,
};
rustfs_common::mrf_channel::note_mrf_verified_repair(MrfVerifiedRepairEvent {
@@ -1135,14 +1251,108 @@ mod tests {
runtime.retained_replay_journal(),
"anchor must retain the startup journal before proof is consumed"
);
assert_eq!(
runtime.replay_cleanup_to_delete(),
None,
"the committed replay source must not be reclaimed before the exact proof"
);
runtime.discharge_durable_replay_anchors();
assert!(
!runtime.retained_replay_journal(),
"matching verified proof discharges the durable replay anchor"
);
assert_eq!(
runtime.replay_cleanup_to_delete(),
Some(cleanup),
"proof discharge must preserve the committed owner/sequence cleanup target"
);
rustfs_common::mrf_channel::release_mrf_intent(&intent);
}
#[test]
fn runtime_cleanup_defaults_to_legacy_for_runtime_written_journals() {
let runtime = MrfRuntime {
queue: MrfQueue::new(2, usize::MAX),
config: MrfConsumerConfig::default(),
checkpoint_owner: Uuid::new_v4(),
next_checkpoint_sequence: 1,
new_since_flush: 0,
dirty: false,
journal_on_disk: true,
retain_replay_journal: false,
durable_replay_anchors: Vec::new(),
replay_cleanup: None,
runtime_checkpoint: None,
backoff_until: None,
};
assert_eq!(
runtime.replay_cleanup_to_delete(),
Some(ReplayCleanup::Legacy),
"journals written by the runtime still use the legacy cleanup path"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn runtime_idle_cleanup_deletes_runtime_and_replay_recovery_anchors() {
let _env = rustfs_test_utils::TestECStoreEnv::builder()
.prefix("rustfs_mrf_runtime_idle_cleanup")
.build()
.await;
let disks = journal_disks().await;
assert!(!disks.is_empty(), "test environment must register local disks");
let replay_owner = Uuid::new_v4();
let runtime_owner = Uuid::new_v4();
let config = MrfConsumerConfig::default();
let journal_max_bytes = config.journal_max_bytes;
let replay_payload = encoded_payload(&intent("cleanup-bucket", "replay-object", 0));
let runtime_payload = encoded_payload(&intent("cleanup-bucket", "runtime-object", 0));
snapshot::publish_committed_snapshot(&disks, replay_owner, 7, &replay_payload, journal_max_bytes)
.await
.expect("publish retained replay checkpoint");
snapshot::publish_committed_snapshot(&disks, runtime_owner, 8, &runtime_payload, journal_max_bytes)
.await
.expect("publish runtime checkpoint");
assert!(write_journal(MRF_SCOPED_JOURNAL_PATH, &runtime_payload).await);
assert!(write_journal(MRF_JOURNAL_PATH, &runtime_payload).await);
let mut runtime = MrfRuntime {
queue: MrfQueue::new(2, usize::MAX),
config,
checkpoint_owner: Uuid::new_v4(),
next_checkpoint_sequence: 9,
new_since_flush: 0,
dirty: false,
journal_on_disk: true,
retain_replay_journal: false,
durable_replay_anchors: Vec::new(),
replay_cleanup: Some(ReplayCleanup::Committed {
owner: replay_owner,
sequence: 7,
}),
runtime_checkpoint: Some((runtime_owner, 8)),
backoff_until: None,
};
assert!(
runtime.delete_idle_recovery_anchors().await,
"idle cleanup should remove both runtime and replay recovery anchors"
);
assert_eq!(runtime.replay_cleanup, None);
assert_eq!(runtime.runtime_checkpoint, None);
assert!(
snapshot::inspect_local_committed_snapshot(journal_max_bytes)
.await
.expect("inspect committed checkpoints after cleanup")
.is_none(),
"both committed checkpoint generations must be gone after idle cleanup"
);
assert_eq!(read_journal(MRF_SCOPED_JOURNAL_PATH).await, None);
assert_eq!(read_journal(MRF_JOURNAL_PATH).await, None);
}
#[test]
fn durable_replay_acquires_a_fresh_lease_before_manager_admission() {
let unique = uuid::Uuid::new_v4();
@@ -1344,6 +1554,25 @@ mod tests {
assert_eq!(truncated, corrupt.len());
}
#[test]
fn journal_rejects_unknown_version_presence_flag_even_with_valid_crc() {
let mut versioned = intent("rollback-bucket", "object", 0);
versioned.version_id = Some([9; 16]);
let mut buf = Vec::new();
assert!(encode_intent(&versioned, &mut buf));
buf[12] = 2;
let crc_offset = buf.len() - 4;
let mut hasher = crc_fast::Digest::new(crc_fast::CrcAlgorithm::Crc32IsoHdlc);
hasher.update(&buf[..crc_offset]);
let checksum = u32::try_from(hasher.finalize()).expect("CRC32 fits");
buf[crc_offset..].copy_from_slice(&checksum.to_le_bytes());
let (decoded, truncated) = decode_journal(&buf);
assert!(decoded.is_empty(), "unknown boolean encodings are not rollback-compatible payloads");
assert_eq!(truncated, buf.len());
}
#[test]
fn heal_request_mapping_follows_priority_matrix() {
let decode = build_heal_request(&intent("b", "o", 0));
+281 -32
View File
@@ -22,8 +22,9 @@
//! responsibility through a newer durable snapshot or a verified repair proof.
//! An unreadable commit path cannot prove that only legacy data exists. This
//! explicit inspection API fails closed and never mutates recovery anchors.
//! It is wired into the replay reader before writer activation, but the writer
//! remains gated on ownership-aware handoff.
//! The live consumer writes committed checkpoints alongside the scoped and
//! legacy journal mirrors; cleanup remains gated by replay ownership and exact
//! verified repair proof handoff.
//! One surviving committed replica supports process restart recovery only;
//! this reader does not establish a replication quorum or a power-loss policy.
@@ -356,8 +357,8 @@ fn validate_reusable_manifest_slot(existing: Option<&[u8]>, sequence: u64, paylo
/// The writer is a narrow production primitive for the ownership-aware MRF
/// handoff: it validates the whole journal payload, preserves the previous
/// committed slot, and publishes the manifest only after the successor payload
/// reaches the same disk. It does not delete legacy journals, tombstone older
/// anchors, or activate the live consumer.
/// reaches the same disk. It does not delete legacy journals or tombstone older
/// anchors by itself; the consumer decides cleanup after replay handoff.
pub async fn publish_committed_snapshot(
disks: &[EcstoreDiskStore],
owner: Uuid,
@@ -371,7 +372,10 @@ pub async fn publish_committed_snapshot(
if owner.is_nil() || sequence == 0 || sequence == u64::MAX {
return Err(SnapshotError::Corrupt);
}
if payload.len() > limit || decode_journal(payload).1 != 0 {
if payload.len() > limit {
return Err(SnapshotError::TooLarge);
}
if decode_journal(payload).1 != 0 {
return Err(SnapshotError::Corrupt);
}
let current = read_committed(disks, limit).await?;
@@ -555,31 +559,35 @@ pub async fn inspect_local_committed_snapshot(max_bytes: usize) -> Result<Option
read_committed(&super::journal_disks().await, max_bytes).await
}
/// Remove committed manifests whose sequence is no newer than
/// Remove committed checkpoints from `owner` whose sequence is no newer than
/// `committed_through`.
///
/// Payload files are intentionally left as orphans after their manifest is
/// removed. Readers cannot discover a payload without its matching manifest,
/// and deleting manifests first prevents an older retained slot from becoming
/// visible again after the newest replay has been fully discharged.
pub async fn delete_committed_snapshots_through(committed_through: u64, max_bytes: usize) -> Result<bool, SnapshotError> {
/// Cleanup is manifest-first so readers cannot rediscover an older payload
/// after the newest replay has been fully discharged. The payload is removed
/// only after the manifest and body were revalidated as one complete committed
/// checkpoint; damaged, future, mismatched, or foreign-owner slots are retained.
pub async fn delete_committed_snapshots_through(
owner: Uuid,
committed_through: u64,
max_bytes: usize,
) -> Result<bool, SnapshotError> {
let disks = super::journal_disks().await;
delete_committed_snapshots_through_on(&disks, committed_through, max_bytes).await
delete_committed_snapshots_through_on(&disks, owner, committed_through, max_bytes).await
}
async fn delete_committed_snapshots_through_on(
disks: &[EcstoreDiskStore],
owner: Uuid,
committed_through: u64,
max_bytes: usize,
) -> Result<bool, SnapshotError> {
if disks.is_empty() {
return Err(SnapshotError::NoWritableReplica);
}
let mut any_changed = false;
let mut first_error = None;
for disk in disks {
for path in MANIFEST_PATHS {
let existing = match read_bounded(disk, path, MANIFEST_LEN).await {
for (manifest_path, payload_path) in MANIFEST_PATHS.into_iter().zip(PAYLOAD_PATHS) {
let manifest_bytes = match read_bounded(disk, manifest_path, MANIFEST_LEN).await {
Ok(Some(existing)) => existing,
Ok(None) => continue,
Err(error) => {
@@ -589,7 +597,7 @@ async fn delete_committed_snapshots_through_on(
continue;
}
};
let manifest = match Manifest::decode(&existing, max_bytes) {
let manifest = match Manifest::decode(&manifest_bytes, max_bytes) {
Ok(manifest) => manifest,
Err(error) => {
if first_error.is_none() {
@@ -598,19 +606,61 @@ async fn delete_committed_snapshots_through_on(
continue;
}
};
if manifest.sequence > committed_through {
if manifest.owner != owner || manifest.sequence > committed_through {
continue;
}
let payload_bytes = match read_bounded(disk, payload_path, manifest.payload_len).await {
Ok(Some(payload)) => payload,
Ok(None) => {
if first_error.is_none() {
first_error = Some(SnapshotError::Corrupt);
}
continue;
}
Err(error) => {
if first_error.is_none() {
first_error = Some(error);
}
continue;
}
};
if let Err(error) = CommittedSnapshot::decode(0, &manifest_bytes, payload_bytes.clone(), max_bytes) {
if first_error.is_none() {
first_error = Some(error);
}
continue;
}
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
path,
Some(EcstoreDiskBytes::from(existing)),
manifest_path,
Some(EcstoreDiskBytes::copy_from_slice(&manifest_bytes)),
None,
)
.await
{
Ok(EcstoreConditionalFileUpdate::Updated) => any_changed = true,
Ok(EcstoreConditionalFileUpdate::Updated) => {
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
payload_path,
Some(EcstoreDiskBytes::copy_from_slice(&payload_bytes)),
None,
)
.await
{
Ok(
EcstoreConditionalFileUpdate::Updated
| EcstoreConditionalFileUpdate::Missing
| EcstoreConditionalFileUpdate::Mismatch,
) => {}
Err(error) => {
if first_error.is_none() {
first_error = Some(SnapshotError::Disk(error));
}
}
}
}
Ok(EcstoreConditionalFileUpdate::Missing | EcstoreConditionalFileUpdate::Mismatch) => {}
Err(error) => {
if first_error.is_none() {
@@ -620,13 +670,7 @@ async fn delete_committed_snapshots_through_on(
}
}
}
if any_changed {
Ok(true)
} else if let Some(error) = first_error {
Err(error)
} else {
Ok(true)
}
if let Some(error) = first_error { Err(error) } else { Ok(true) }
}
async fn read_recovery_snapshot(disks: &[EcstoreDiskStore], limit: usize) -> Result<Option<RecoverySnapshot>, SnapshotError> {
@@ -1096,6 +1140,45 @@ mod tests {
);
}
#[tokio::test]
async fn committed_snapshot_writer_capacity_failure_preserves_previous_anchor() {
let root = TempDir::new().expect("test directory");
let store = disk(&root, "disk").await;
let owner = Uuid::new_v4();
let old = payload("old");
let next = payload("next");
commit(&store, 0, owner, 1, &old).await;
let result = publish_committed_snapshot(std::slice::from_ref(&store), owner, 2, &next, next.len() - 1).await;
assert!(
matches!(result, Err(SnapshotError::TooLarge)),
"capacity failure must be reported separately from corruption: {result:?}"
);
let reopened = disk(&root, "disk").await;
let recovered = read_committed(std::slice::from_ref(&reopened), 4096)
.await
.expect("read previous committed snapshot")
.expect("old anchor remains committed");
assert_eq!(recovered.sequence(), 1);
assert_eq!(recovered.slot(), 0);
assert_eq!(recovered.payload(), old.as_slice());
assert_eq!(
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[0])
.await
.expect("old manifest retained")
.as_ref(),
manifest(owner, 1, &old).as_slice()
);
assert!(
matches!(
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[1]).await,
Err(EcstoreDiskError::FileNotFound | EcstoreDiskError::VolumeNotFound)
),
"oversized successor payload must not be staged"
);
}
#[tokio::test]
async fn committed_snapshot_writer_does_not_overwrite_damaged_inactive_manifest() {
let root = TempDir::new().expect("test directory");
@@ -1200,6 +1283,83 @@ mod tests {
assert_eq!(recovered.manifest.sequence, 1);
}
#[tokio::test]
async fn manifest_cas_failure_after_payload_write_keeps_previous_anchor() {
let root = TempDir::new().expect("test directory");
let store = disk(&root, "disk").await;
let owner = Uuid::new_v4();
let old = payload("old");
let next = payload("next");
let damaged_manifest = b"damaged successor manifest".to_vec();
commit(&store, 0, owner, 1, &old).await;
let expected_manifest = EcstoreDiskAPI::read_all(store.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[1])
.await
.ok();
assert_eq!(
cas_replace(&store, PAYLOAD_PATHS[1], &next, 4096)
.await
.expect("successor payload CAS"),
EcstoreConditionalFileUpdate::Updated
);
install(&store, MANIFEST_PATHS[1], &damaged_manifest).await;
let manifest_update = cas_replace_expected(&store, MANIFEST_PATHS[1], expected_manifest, &manifest(owner, 2, &next))
.await
.expect("successor manifest CAS");
assert_eq!(manifest_update, EcstoreConditionalFileUpdate::Mismatch);
let reopened = disk(&root, "disk").await;
let recovered = read_committed(std::slice::from_ref(&reopened), 4096)
.await
.expect("read committed snapshot after failed successor CAS")
.expect("previous committed anchor");
assert_eq!(recovered.sequence(), 1);
assert_eq!(recovered.slot(), 0);
assert_eq!(recovered.payload(), old.as_slice());
assert_eq!(
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[1])
.await
.expect("successor payload remains non-authoritative")
.as_ref(),
next.as_slice()
);
assert_eq!(
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[1])
.await
.expect("failed successor manifest retained")
.as_ref(),
damaged_manifest.as_slice()
);
}
#[tokio::test]
async fn torn_successor_on_one_replica_does_not_hide_previous_anchor_on_peer() {
let root = TempDir::new().expect("test directory");
let first = disk(&root, "first").await;
let second = disk(&root, "second").await;
let owner = Uuid::new_v4();
let old = payload("old");
let next = payload("next");
let damaged_manifest = b"damaged successor manifest".to_vec();
commit(&first, 0, owner, 1, &old).await;
commit(&second, 0, owner, 1, &old).await;
install(&first, PAYLOAD_PATHS[1], &next).await;
install(&first, MANIFEST_PATHS[1], &damaged_manifest).await;
let mut stats = SnapshotReadStats::default();
let recovered = read_committed_with_stats(&[first, second], 4096, Some(&mut stats))
.await
.expect("read committed snapshot across torn successor")
.expect("previous committed anchor");
assert_eq!(recovered.sequence(), 1);
assert_eq!(recovered.payload(), old.as_slice());
assert_eq!(stats.file_reads, 5);
assert_eq!(stats.bytes_read, (MANIFEST_LEN * 2) + (old.len() * 2) + damaged_manifest.len());
assert_eq!(stats.peak_file_bytes, old.len().max(next.len()).max(MANIFEST_LEN));
}
#[tokio::test]
async fn manifest_cas_publication_transitions_from_legacy_without_losing_anchor() {
let root = TempDir::new().expect("test directory");
@@ -1273,7 +1433,7 @@ mod tests {
}
#[tokio::test]
async fn committed_cleanup_removes_only_manifests_at_or_below_sequence() {
async fn committed_cleanup_removes_complete_checkpoint_at_or_below_sequence() {
let root = TempDir::new().expect("test directory");
let disk = disk(&root, "disk").await;
let owner = Uuid::new_v4();
@@ -1283,7 +1443,7 @@ mod tests {
commit(&disk, 1, owner, 4, &newer).await;
assert!(
delete_committed_snapshots_through_on(std::slice::from_ref(&disk), 3, 4096)
delete_committed_snapshots_through_on(std::slice::from_ref(&disk), owner, 3, 4096)
.await
.expect("delete old manifest"),
"old committed manifest should be removed"
@@ -1295,12 +1455,19 @@ mod tests {
),
"old manifest is gone, so the old payload cannot become visible again"
);
assert!(
matches!(
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[0]).await,
Err(EcstoreDiskError::FileNotFound | EcstoreDiskError::VolumeNotFound)
),
"old payload should be reclaimed after its manifest is removed"
);
assert_eq!(
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[0])
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[1])
.await
.expect("old payload orphan may remain")
.expect("newer payload retained")
.as_ref(),
older
newer
);
let recovered = read_committed(std::slice::from_ref(&disk), 4096)
.await
@@ -1310,6 +1477,88 @@ mod tests {
assert_eq!(recovered.payload(), newer);
}
#[tokio::test]
async fn committed_cleanup_retains_manifest_when_payload_identity_mismatches() {
let root = TempDir::new().expect("test directory");
let disk = disk(&root, "disk").await;
let owner = Uuid::new_v4();
let declared = payload("declared");
let actual = payload("actual");
install(&disk, MANIFEST_PATHS[0], &manifest(owner, 3, &declared)).await;
install(&disk, PAYLOAD_PATHS[0], &actual).await;
assert!(
matches!(
delete_committed_snapshots_through_on(std::slice::from_ref(&disk), owner, 3, 4096).await,
Err(SnapshotError::Corrupt)
),
"cleanup must fail closed when the committed body no longer matches its manifest"
);
assert_eq!(
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[0])
.await
.expect("mismatched manifest retained")
.as_ref(),
manifest(owner, 3, &declared)
);
assert_eq!(
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[0])
.await
.expect("mismatched payload retained")
.as_ref(),
actual
);
}
#[tokio::test]
async fn committed_cleanup_preserves_other_owner_manifests_within_sequence_window() {
let root = TempDir::new().expect("test directory");
let disk = disk(&root, "disk").await;
let replay_owner = Uuid::new_v4();
let other_owner = Uuid::new_v4();
let replay_payload = payload("replay-owner");
let other_payload = payload("other-owner");
commit(&disk, 0, replay_owner, 9, &replay_payload).await;
commit(&disk, 1, other_owner, 4, &other_payload).await;
assert!(
delete_committed_snapshots_through_on(std::slice::from_ref(&disk), replay_owner, 9, 4096)
.await
.expect("delete replay-owner manifest"),
"the matched owner manifest should be removed"
);
assert!(
matches!(
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[0]).await,
Err(EcstoreDiskError::FileNotFound | EcstoreDiskError::VolumeNotFound)
),
"the replay owner's manifest is reclaimed"
);
assert!(
matches!(
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[0]).await,
Err(EcstoreDiskError::FileNotFound | EcstoreDiskError::VolumeNotFound)
),
"the replay owner's payload is reclaimed"
);
assert_eq!(
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[1])
.await
.expect("other owner manifest retained")
.as_ref(),
manifest(other_owner, 4, &other_payload)
);
assert_eq!(
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[1])
.await
.expect("other owner payload retained")
.as_ref(),
other_payload
);
}
#[tokio::test]
async fn legacy_import_requires_complete_consistent_replicas() {
let root = TempDir::new().expect("test directory");
+1 -1
View File
@@ -45,7 +45,7 @@ use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, RUSTFS_META_BUCKET};
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, POOL_META_NAME, RUSTFS_META_BUCKET};
#[cfg(test)]
pub(crate) struct OutcomeFinishTestHook {
+79
View File
@@ -340,9 +340,88 @@ impl HealTask {
return Err(self.record_batch_failure(failure).await);
}
if self.options.recreate_missing && !self.options.dry_run {
self.heal_cluster_pool_metadata().await?;
}
Ok(())
}
async fn heal_cluster_pool_metadata(&self) -> Result<()> {
let heal_opts = HealOpts {
recursive: false,
dry_run: self.options.dry_run,
remove: false,
recreate: self.options.recreate_missing,
scan_mode: self.options.scan_mode,
update_parity: self.options.update_parity,
no_lock: self.options.no_lock,
read_repair: false,
pool: self.options.pool_index,
set: self.options.set_index,
};
let heal_result = self
.await_with_control(self.storage.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &heal_opts))
.await;
match heal_result {
Ok((result, None)) => {
debug!(
target: "rustfs::heal::task",
event = EVENT_HEAL_BUCKET_RESULT,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_TASK,
task_id = %self.id,
bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME,
drives_healed = result.drives_healed(),
drives_total = result.drives_reported(),
result = "pool_metadata_ok",
"Heal cluster pool metadata repaired"
);
self.record_result_item(result).await;
Ok(())
}
Ok((result, Some(err))) => {
self.record_result_item(result).await;
warn!(
target: "rustfs::heal::task",
event = EVENT_HEAL_BUCKET_RESULT,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_TASK,
task_id = %self.id,
bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME,
result = "pool_metadata_failed",
error = %err,
"Heal cluster pool metadata failed"
);
Err(Error::TaskExecutionFailed {
message: format!("Failed to heal cluster pool metadata: {err}"),
})
}
Err(Error::TaskCancelled) => Err(Error::TaskCancelled),
Err(Error::TaskTimeout) => Err(Error::TaskTimeout),
Err(err) => {
warn!(
target: "rustfs::heal::task",
event = EVENT_HEAL_BUCKET_RESULT,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_TASK,
task_id = %self.id,
bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME,
result = "pool_metadata_failed",
error = %err,
"Heal cluster pool metadata failed"
);
Err(Error::TaskExecutionFailed {
message: format!("Failed to heal cluster pool metadata: {err}"),
})
}
}
}
pub(super) async fn heal_prefix(&self, bucket: &str, prefix: &str) -> Result<()> {
debug!(
target: "rustfs::heal::task",
@@ -451,6 +451,11 @@ impl HealTask {
self.source,
)
.with_replacement_targets(replacement_targets, is_auto_replacement.then(|| self.id.clone()))
.with_pool_metadata_targets(if self.options.recreate_missing && !self.options.dry_run {
self.heal_endpoints.clone()
} else {
Vec::new()
})
.with_replacement_identity_fence(replacement_target_identities.clone())
.with_mainline_pacer(self.mainline_pacer.clone());
-5
View File
@@ -162,11 +162,6 @@ impl HealTask {
pool: self.options.pool_index,
set: self.options.set_index,
};
let expected_bucket_incarnation_id = self.storage.bucket_incarnation_id(bucket).await?;
let mut expected_identity =
self.outcome_identity(bucket, object, version_id, self.options.pool_index, self.options.set_index);
expected_identity.bucket_incarnation_id = expected_bucket_incarnation_id;
let mut expected_identity =
self.outcome_identity(bucket, object, version_id, self.options.pool_index, self.options.set_index);
expected_identity.bucket_incarnation_id = self.outcome_bucket_incarnation_id(bucket, self.options.dry_run).await?;
+86 -2
View File
@@ -66,7 +66,7 @@ mod canonical_outcome {
assert_eq!(task.get_progress().await.objects_scanned, 2);
assert_eq!(
storage.heal_object_calls.lock().expect("object calls").as_slice(),
["object-a", "object-b"]
["object-a", "object-b", POOL_META_NAME]
);
assert_eq!(
storage.listing_tokens.lock().expect("listing tokens").as_slice(),
@@ -2582,11 +2582,95 @@ async fn test_cluster_heal_visits_bucket_objects() {
assert_eq!(
storage.healed_objects.lock().unwrap().as_slice(),
["object-a".to_string(), "object-b".to_string()]
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
);
assert!(matches!(task.get_status().await, HealTaskStatus::Completed));
}
#[tokio::test]
async fn cluster_recreate_heals_pool_metadata_after_user_buckets() {
let storage = Arc::new(MockStorage::default());
let request = HealRequest::new(
HealType::Cluster,
HealOptions {
recursive: true,
recreate_missing: true,
timeout: None,
..Default::default()
},
HealPriority::Normal,
);
let task = HealTask::from_request(request, storage.clone());
task.execute()
.await
.expect("cluster recreate heal should include pool metadata");
assert_eq!(
storage.heal_object_calls.lock().expect("object calls").as_slice(),
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
);
let opts = storage.object_heal_opts.lock().expect("object opts");
assert!(opts.last().expect("pool metadata opts").recreate);
}
#[tokio::test]
async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
let storage = Arc::new(MockStorage::default());
storage.heal_object_outcomes.lock().expect("object outcomes").insert(
POOL_META_NAME.to_string(),
VecDeque::from([MockHealObjectOutcome::ErrOther("pool metadata missing")]),
);
let request = HealRequest::new(
HealType::Cluster,
HealOptions {
recursive: true,
recreate_missing: true,
timeout: None,
..Default::default()
},
HealPriority::Normal,
);
let task = HealTask::from_request(request, storage.clone());
let err = task
.execute()
.await
.expect_err("cluster recreate heal must not hide pool metadata failure");
assert!(matches!(err, Error::TaskExecutionFailed { .. }));
assert_eq!(
storage.heal_object_calls.lock().expect("object calls").as_slice(),
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
);
}
#[tokio::test]
async fn cluster_dry_run_does_not_heal_pool_metadata() {
let storage = Arc::new(MockStorage::default());
let request = HealRequest::new(
HealType::Cluster,
HealOptions {
recursive: true,
dry_run: true,
recreate_missing: true,
timeout: None,
..Default::default()
},
HealPriority::Normal,
);
let task = HealTask::from_request(request, storage.clone());
task.execute()
.await
.expect("dry-run cluster heal should preserve existing coverage");
assert_eq!(
storage.heal_object_calls.lock().expect("object calls").as_slice(),
["object-a".to_string(), "object-b".to_string()]
);
}
#[tokio::test]
async fn object_heal_skips_dangling_delete_grace_without_failing_task() {
let storage = Arc::new(MockStorage {
+44 -2
View File
@@ -51,6 +51,8 @@ const JOURNAL_REL: &str = "buckets/.heal/mrf/journal.bin";
const SCOPED_JOURNAL_REL: &str = "buckets/.heal/mrf/journal-scoped.bin";
const COMMITTED_PAYLOAD_REL: &str = ".heal-mrf-snapshot.0.bin";
const COMMITTED_MANIFEST_REL: &str = ".heal-mrf-commit.0.bin";
const COMMITTED_PAYLOAD_RELS: [&str; 2] = [".heal-mrf-snapshot.0.bin", ".heal-mrf-snapshot.1.bin"];
const COMMITTED_MANIFEST_RELS: [&str; 2] = [".heal-mrf-commit.0.bin", ".heal-mrf-commit.1.bin"];
const COMMITTED_MAGIC: &[u8; 8] = b"RFMRFC01";
const COMMITTED_MANIFEST_LEN: usize = 8 + 1 + 16 + 8 + 8 + 32 + 32;
@@ -235,6 +237,44 @@ fn journal_matches_on_all_disks(disk_paths: &[PathBuf], relative_path: &str, exp
.all(|path| std::fs::read(path.join(META_BUCKET).join(relative_path)).is_ok_and(|actual| actual == expected))
}
fn committed_checkpoint_matches_on_all_disks(disk_paths: &[PathBuf], sequence: u64, expected_payload: &[u8]) -> bool {
disk_paths.iter().all(|path| {
let root = path.join(META_BUCKET);
COMMITTED_PAYLOAD_RELS
.into_iter()
.zip(COMMITTED_MANIFEST_RELS)
.any(|(payload_rel, manifest_rel)| {
let Ok(payload) = std::fs::read(root.join(payload_rel)) else {
return false;
};
if payload != expected_payload {
return false;
}
let Ok(manifest) = std::fs::read(root.join(manifest_rel)) else {
return false;
};
if manifest.len() != COMMITTED_MANIFEST_LEN || &manifest[..8] != COMMITTED_MAGIC || manifest[8] != 1 {
return false;
}
let Ok(recorded_sequence) = <[u8; 8]>::try_from(&manifest[25..33]).map(u64::from_le_bytes) else {
return false;
};
let Ok(recorded_len) = <[u8; 8]>::try_from(&manifest[33..41]).map(u64::from_le_bytes) else {
return false;
};
let Ok(expected_len) = u64::try_from(expected_payload.len()) else {
return false;
};
if recorded_sequence != sequence || recorded_len != expected_len {
return false;
}
let payload_digest: [u8; 32] = Sha256::digest(expected_payload).into();
let manifest_digest: [u8; 32] = Sha256::digest(&manifest[..COMMITTED_MANIFEST_LEN - 32]).into();
payload_digest.as_slice() == &manifest[41..73] && manifest_digest.as_slice() == &manifest[73..]
})
})
}
async fn wait_until<F, Fut>(deadline: Duration, mut probe: F) -> bool
where
F: FnMut() -> Fut,
@@ -629,13 +669,14 @@ fn mrf_successor_flush_child_process_fixture() {
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
let flushed = wait_until(Duration::from_secs(10), || async {
manager.operations_snapshot().await.queued_by_source.mrf == 1
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
&& journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor)
&& journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &expected_successor)
})
.await;
assert!(
flushed,
"child process must publish the pending successor snapshot before the delete phase"
"child process must publish the committed pending successor before the delete phase"
);
});
std::process::exit(78);
@@ -676,13 +717,14 @@ fn mrf_successor_flush_waiting_child_process_fixture() {
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
let flushed = wait_until(Duration::from_secs(10), || async {
manager.operations_snapshot().await.queued_by_source.mrf == 1
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
&& journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor)
&& journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &expected_successor)
})
.await;
assert!(
flushed,
"child process must publish the pending successor snapshot before it can be killed"
"child process must publish the committed pending successor before it can be killed"
);
std::fs::write(&ready_path, b"ready").expect("write ready marker");
loop {
+3 -3
View File
@@ -39,8 +39,8 @@ The `scanner` and `heal` subsystems are served by `GetConfigKVHandler` (`rustfs/
The `--abba` mode runs five independent scenario cells: `cold-hot`, `fresh-hot`,
`multi-hot-new`, `running-heal`, and `mrf-replay`. Each scenario runs at least
three A1/B1/B2/A2 groups for both baseline/candidate with background work on,
and candidate-only background off/on. A measured leg lasts at least 900
seconds; the minimum matrix contains 120 legs (30 hours before setup/oracles).
and candidate-only background off/on. A measured release leg lasts at least 7200
seconds; the minimum matrix contains 120 legs (240 hours before setup/oracles).
The existing `performance-ab.yml` supplies the pattern for immutable build
provenance and failure propagation, but its short Warp workload is not this
scanner gate. No scheduled workflow starts this matrix automatically.
@@ -63,7 +63,7 @@ The manifest has the following JSON contract (all fields are required):
| Field | Value |
|---|---|
| `schema`, `evidence` | `1`, and `measured` or `synthetic`. |
| `rounds`, `duration_seconds`, `min_free_bytes` | 3..10 groups, 900..86400 seconds for measured runs, and the independently estimated free-space reservation in bytes. Synthetic runs may use 1 second. |
| `rounds`, `duration_seconds`, `min_free_bytes` | 3..10 groups, 7200..86400 seconds for measured release runs, and the independently estimated free-space reservation in bytes. Synthetic runs may use 1 second. |
| `baseline`, `candidate` | Each contains executable `binary`, full 40-character `revision`, and verified `sha256`. The runner rehashes binaries before every leg. |
| `fixed` | `config_sha256`, `dataset_sha256`, `release_flags`, `durability`, `disk_type`, `cache_state`, `load_command`, `resource_isolation`, `topology` (`EC8+4`), and positive `offered_load_ops`. Hashes use 64 lowercase hexadecimal characters. |
| `release_evidence` | Required for `measured` runs. It binds the 3x4 EC8+4 topology, multi-pool/multi-set coverage, per-node metrics endpoints, same-window distributed sampling, process restart and crash-restart fault modes, mixed-version reader/writer/rollback participation, and allocation/flamegraph/RSS/save-frequency profile artifact requirements. Synthetic runs do not need this field and still cannot approve release evidence. |
+7 -3
View File
@@ -288,9 +288,13 @@ measured durations, P3's pressure run needs at least two hours, and P1 needs a
symbolized profile summary with resolved samples. Every G14 field and every
performance gate's fields must also share one `measurement_window_id`, so EC8+4,
multi-set/multi-pool, ABBA, throughput, and profiling artifacts cannot be
stitched together from unrelated runs. Missing, synthetic, stale, tampered,
undersized, or topology-mismatched evidence returns a compact blocked or invalid
JSON result and a nonzero exit.
stitched together from unrelated runs. P1 `profile_evidence` must bind every
required profile artifact kind (`allocation-profile`, `flamegraph`,
`rss-samples`, and `save-frequency`) with a relative path, artifact format,
non-empty file, matching SHA256, and the same measurement window when a
per-artifact window is declared. Missing, synthetic, stale, tampered, undersized,
or topology-mismatched evidence returns a compact blocked or invalid JSON result
and a nonzero exit.
This command validates the evidence package; it does not create evidence. A
handwritten JSON file, a synthetic harness pass, a single focused case, or a
+163 -5
View File
@@ -23,7 +23,16 @@ try:
except ModuleNotFoundError:
import tomli as tomllib
from scanner_abba import MAX_JSON_BYTES, digest, number, read_json, require, sha, write_json
from scanner_abba import (
MAX_JSON_BYTES,
RELEASE_PROFILE_ARTIFACTS,
digest,
number,
read_json,
require,
sha,
write_json,
)
ROOT = Path(__file__).resolve().parents[1]
@@ -68,6 +77,24 @@ SCANNER_HEAL_RELEASE_BUNDLE_REQUIRED_EVIDENCE_FIELDS = {
"R-D": ("manager_disposition_evidence", "event_disposition_evidence", "ledger_disposition_evidence", "grace_handling"),
"R-L": ("legacy_source_conflict_evidence", "migration_gap_evidence", "crash_safe_source_retirement_evidence"),
}
SCANNER_HEAL_RELEASE_MIXED_VERSION_ROLES = {
("G03", "durable_root_publication_proof"): "durable-root-publication",
("G03", "scoped_ack_request_identity"): "scoped-ack-request",
("G03", "participating_peer_capability_snapshot"): "peer-capability-snapshot",
("G03", "mixed_peer_ack_fallback_oracle"): "mixed-peer-ack-fallback",
("G09", "mixed_version_reader_evidence"): "mixed-version-reader",
("G09", "mixed_version_writer_evidence"): "mixed-version-writer",
("G09", "rollback_payload_evidence"): "rollback-payload",
("R-L", "legacy_source_conflict_evidence"): "legacy-source-conflict",
("R-L", "migration_gap_evidence"): "migration-gap",
("R-L", "crash_safe_source_retirement_evidence"): "crash-safe-source-retirement",
}
SCANNER_HEAL_RELEASE_MRF_DURABLE_REPLAY_FIELDS = {
("G07", "mrf_responsibility_oracle"),
("G07", "commit_boundary_crash_matrix"),
("P4", "mrf_replay_cost_measurement"),
("P4", "retained_responsibility_evidence"),
}
SCHEDULED_ALERT_WORKFLOWS = tuple(
item["workflow"]
for item in json.loads((ROOT / ".github/scheduled-validations.json").read_text())
@@ -956,6 +983,9 @@ def scanner_heal_oracle_names(root: Path) -> tuple[str, ...]:
names = set()
for case_id, requirement in cases.items():
require(isinstance(case_id, str) and case_id, "invalid scanner/heal case identity")
lane = requirement.get("lane")
require(isinstance(lane, str) and re.fullmatch(r"[a-z0-9-]+", lane) is not None,
f"invalid nextest profile lane for {case_id}")
oracle = requirement.get("oracle")
require(isinstance(oracle, str) and oracle.endswith(".json"), f"invalid oracle for {case_id}")
path = Path(oracle)
@@ -1303,6 +1333,7 @@ def release_bundle_artifact_path(bundle_path: Path, raw_path: object, gate: str,
resolved = (bundle_path.parent / path).resolve()
require(resolved.is_relative_to(bundle_path.parent.resolve()), f"{gate}.{field} artifact path escapes bundle directory")
require(resolved.is_file(), f"{gate}.{field} artifact is missing")
require(resolved.stat().st_size > 0, f"{gate}.{field} artifact is empty")
return resolved
@@ -1338,13 +1369,35 @@ def validate_release_bundle_artifact(bundle_path: Path, source_revision: str, ga
if gate in ("G03", "G09", "R-L"):
versions = evidence.get("versions")
require(isinstance(versions, list) and
len({version for version in versions if isinstance(version, str) and version.strip()}) >= 2,
len(set(versions)) >= 2 and
all(isinstance(version, str) and re.fullmatch(r"[0-9a-f]{40}", version) is not None
for version in versions),
f"{gate}.{field} requires mixed-version evidence")
require(source_revision in versions, f"{gate}.{field} versions omit tested source revision")
expected_role = SCANNER_HEAL_RELEASE_MIXED_VERSION_ROLES[(gate, field)]
require(evidence.get("mixed_version_role") == expected_role,
f"{gate}.{field} mixed-version role must be {expected_role}")
if gate in ("G04", "G07", "R-E", "R-L"):
crash_points = evidence.get("crash_points")
require(isinstance(crash_points, list) and crash_points,
f"{gate}.{field} requires crash-boundary evidence")
if (gate, field) in SCANNER_HEAL_RELEASE_MRF_DURABLE_REPLAY_FIELDS:
evidence_integer(evidence.get("replayed_records"), f"{gate}.{field}.replayed_records", 1, 2**63 - 1)
require(evidence.get("responsibility_anchor_retained") is True,
f"{gate}.{field} requires retained MRF responsibility anchors")
require(evidence.get("successor_snapshot_published") is True,
f"{gate}.{field} requires successor snapshot publication evidence")
if gate == "G14":
if field == "same_window_field_evidence":
required_fields = set(SCANNER_HEAL_RELEASE_BUNDLE_REQUIRED_EVIDENCE_FIELDS["G14"]) - {field}
same_window_fields = evidence.get("same_window_fields")
require(isinstance(same_window_fields, list) and
len(set(same_window_fields)) == len(same_window_fields) and
all(isinstance(item, str) and item in required_fields for item in same_window_fields),
"G14.same_window_field_evidence requires named G14 field coverage")
missing_same_window_fields = sorted(required_fields - set(same_window_fields))
require(not missing_same_window_fields,
"G14.same_window_field_evidence missing fields: " + ", ".join(missing_same_window_fields))
if field == "ec8_4_evidence":
topology = evidence.get("topology")
require(isinstance(topology, dict), "G14.ec8_4_evidence missing topology")
@@ -1359,6 +1412,29 @@ def validate_release_bundle_artifact(bundle_path: Path, source_revision: str, ga
evidence_integer(evidence.get("pools"), "G14 multi_pool_evidence.pools", 2, 1024)
if field == "profile_evidence":
evidence_integer(evidence.get("resolved_samples"), f"{gate}.{field}.resolved_samples", 1, 2**63 - 1)
profile_artifacts = evidence.get("profile_artifacts")
require(isinstance(profile_artifacts, dict), f"{gate}.{field} missing profile artifacts")
missing_artifacts = sorted(set(RELEASE_PROFILE_ARTIFACTS) - set(profile_artifacts))
require(not missing_artifacts,
f"{gate}.{field} missing profile artifacts: {', '.join(missing_artifacts)}")
unknown_artifacts = sorted(set(profile_artifacts) - set(RELEASE_PROFILE_ARTIFACTS))
require(not unknown_artifacts,
f"{gate}.{field} unknown profile artifacts: {', '.join(unknown_artifacts)}")
for artifact_kind in RELEASE_PROFILE_ARTIFACTS:
item = profile_artifacts[artifact_kind]
require(isinstance(item, dict), f"{gate}.{field}.{artifact_kind} must be an object")
artifact_field = f"{field}.{artifact_kind}"
artifact_path = release_bundle_artifact_path(bundle_path, item.get("artifact"), gate, artifact_field)
require(sha(item.get("sha256")) and digest(artifact_path) == item["sha256"],
f"{gate}.{artifact_field} artifact hash mismatch")
evidence_string(item.get("artifact_format"), f"{gate}.{artifact_field}.artifact_format",
r"[A-Za-z0-9][A-Za-z0-9._+:-]{1,63}")
if "measurement_window_id" in item:
require(item["measurement_window_id"] == window_id,
f"{gate}.{artifact_field} measurement window mismatch")
if "resolved_samples" in item:
evidence_integer(item.get("resolved_samples"), f"{gate}.{artifact_field}.resolved_samples",
0, 2**63 - 1)
return window_id
@@ -1667,6 +1743,16 @@ class SelfTests(unittest.TestCase):
finish_scanner_heal_receipt(run_dir, 0, root)
return root, run_dir
def test_scanner_heal_case_lane_is_required_for_runner_profile(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root, _ = self.scanner_heal_fixture(Path(tmp))
registry_path = root / ".config/scanner-heal-required-tests.json"
registry = read_json(registry_path)
del registry["cases"]["ec84-target-drive-restart"]["lane"]
write_json(registry_path, registry)
with self.assertRaisesRegex(ValueError, "invalid nextest profile lane for ec84-target-drive-restart"):
scanner_heal_oracle_names(root)
def scanner_heal_release_bundle_fixture(self, directory: Path) -> tuple[Path, Path]:
"""Parser fixtures only; the bundle is not runtime evidence."""
root, _ = self.scanner_heal_fixture(directory)
@@ -1704,17 +1790,38 @@ class SelfTests(unittest.TestCase):
evidence["duration_seconds"] = duration
evidence["finished_at"] = (started + timedelta(seconds=duration)).isoformat().replace("+00:00", "Z")
if gate in ("G03", "G09", "R-L"):
evidence["versions"] = ["previous", "candidate"]
evidence["versions"] = ["a" * 40, source_revision]
evidence["mixed_version_role"] = SCANNER_HEAL_RELEASE_MIXED_VERSION_ROLES[(gate, field)]
if gate in ("G04", "G07", "R-E", "R-L"):
evidence["crash_points"] = ["before-commit"]
if (gate, field) in SCANNER_HEAL_RELEASE_MRF_DURABLE_REPLAY_FIELDS:
evidence["replayed_records"] = 2
evidence["responsibility_anchor_retained"] = True
evidence["successor_snapshot_published"] = True
if gate == "G14" and field == "ec8_4_evidence":
evidence["topology"] = {"erasure": "EC8+4", "nodes": 3, "drives_per_node": 4}
if gate == "G14" and field == "same_window_field_evidence":
evidence["same_window_fields"] = [
item
for item in SCANNER_HEAL_RELEASE_BUNDLE_REQUIRED_EVIDENCE_FIELDS["G14"]
if item != "same_window_field_evidence"
]
if gate == "G14" and field == "multi_set_evidence":
evidence["sets"] = 2
if gate == "G14" and field == "multi_pool_evidence":
evidence["pools"] = 2
if field == "profile_evidence":
evidence["resolved_samples"] = 1
artifacts = {}
for artifact_kind in RELEASE_PROFILE_ARTIFACTS:
artifact = artifact_dir / f"{gate}-{field}-{artifact_kind}.json"
write_json(artifact, {"gate": gate, "field": field, "artifact": artifact_kind})
artifacts[artifact_kind] = {
"artifact": artifact.relative_to(bundle_dir).as_posix(),
"sha256": digest(artifact),
"artifact_format": "json",
}
evidence["profile_artifacts"] = artifacts
fields[field] = evidence
gates[gate] = {
"status": "pass",
@@ -1738,7 +1845,7 @@ class SelfTests(unittest.TestCase):
self.assertEqual(status["pending_lanes"], [])
def test_scanner_heal_release_bundle_rejects_synthetic_or_missing_fields(self) -> None:
for fault in ("synthetic", "missing-field", "hash"):
for fault in ("synthetic", "missing-field", "hash", "empty-artifact"):
with self.subTest(fault=fault), tempfile.TemporaryDirectory() as tmp:
root, bundle = self.scanner_heal_release_bundle_fixture(Path(tmp))
data = read_json(bundle)
@@ -1746,9 +1853,12 @@ class SelfTests(unittest.TestCase):
data["evidence"] = "synthetic"
elif fault == "missing-field":
del data["gates"]["G09"]["evidence_fields"]["rollback_payload_evidence"]
else:
elif fault == "hash":
artifact = bundle.parent / data["gates"]["G01"]["evidence_fields"]["root_authority_evidence"]["artifact"]
artifact.write_text(artifact.read_text(encoding="utf-8") + "\n", encoding="utf-8")
else:
artifact = bundle.parent / data["gates"]["G01"]["evidence_fields"]["root_authority_evidence"]["artifact"]
artifact.write_text("", encoding="utf-8")
write_json(bundle, data)
with mock.patch("subprocess.check_output", return_value="b" * 40):
@@ -1768,7 +1878,35 @@ class SelfTests(unittest.TestCase):
("missing-duration", "P1", "cold_walk_share_measurement", lambda item: item.pop("duration_seconds"), "duration_seconds"),
("duration", "P3", "two_hour_pressure_measurement", lambda item: item.update({"duration_seconds": 7199}), "two hours"),
("profile", "P1", "profile_evidence", lambda item: item.pop("resolved_samples"), "resolved_samples"),
(
"profile-artifact",
"P1",
"profile_evidence",
lambda item: item["profile_artifacts"].pop("flamegraph"),
"missing profile artifacts",
),
(
"profile-artifact-hash",
"P1",
"profile_evidence",
lambda item: item["profile_artifacts"]["rss-samples"].update({"sha256": "0" * 64}),
"artifact hash mismatch",
),
(
"profile-artifact-window",
"P1",
"profile_evidence",
lambda item: item["profile_artifacts"]["save-frequency"].update(
{"measurement_window_id": "p1-different-window"}
),
"measurement window mismatch",
),
("versions", "G09", "mixed_version_reader_evidence", lambda item: item.update({"versions": [1, 2]}), "mixed-version"),
("stale-versions", "G09", "mixed_version_writer_evidence", lambda item: item.update({"versions": ["a" * 40, "c" * 40]}), "tested source revision"),
("mrf-records", "G07", "mrf_responsibility_oracle", lambda item: item.pop("replayed_records"), "replayed_records"),
("mrf-anchor", "G07", "commit_boundary_crash_matrix", lambda item: item.update({"responsibility_anchor_retained": False}), "retained MRF responsibility anchors"),
("mrf-successor", "P4", "retained_responsibility_evidence", lambda item: item.pop("successor_snapshot_published"), "successor snapshot"),
("same-window-fields", "G14", "same_window_field_evidence", lambda item: item.update({"same_window_fields": ["ec8_4_evidence", "multi_set_evidence"]}), "missing fields"),
):
with self.subTest(fault=fault), tempfile.TemporaryDirectory() as tmp:
root, bundle = self.scanner_heal_release_bundle_fixture(Path(tmp))
@@ -1782,6 +1920,26 @@ class SelfTests(unittest.TestCase):
self.assertFalse(status["release_approved"])
self.assertTrue(any(expected in error for error in status["rejected_gates"][gate]))
def test_scanner_heal_release_bundle_requires_mixed_version_field_roles(self) -> None:
for gate, field, wrong_role in (
("G03", "mixed_peer_ack_fallback_oracle", "mixed-version-reader"),
("G09", "mixed_version_reader_evidence", "mixed-version-writer"),
("G09", "mixed_version_writer_evidence", "mixed-version-reader"),
("G09", "rollback_payload_evidence", "mixed-version-reader"),
("R-L", "crash_safe_source_retirement_evidence", "migration-gap"),
):
with self.subTest(gate=gate, field=field), tempfile.TemporaryDirectory() as tmp:
root, bundle = self.scanner_heal_release_bundle_fixture(Path(tmp))
data = read_json(bundle)
data["gates"][gate]["evidence_fields"][field]["mixed_version_role"] = wrong_role
write_json(bundle, data)
with mock.patch("subprocess.check_output", return_value="b" * 40):
status = scanner_heal_release_bundle_status(root, bundle)
self.assertEqual(status["decision"], "blocked")
self.assertFalse(status["release_approved"])
self.assertTrue(any("mixed-version role" in error for error in status["rejected_gates"][gate]))
def test_scanner_heal_release_bundle_requires_field_provenance(self) -> None:
for fault, mutation, expected in (
("source", lambda item: item.update({"source_revision": "c" * 40}), "source revision mismatch"),
+69 -19
View File
@@ -4,7 +4,7 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON_BIN="${RUSTFS_PYTHON_BIN:-python3}"
PROFILE="e2e-nightly"
PROFILE=""
CASE_ID="background-target-crash"
RUN_DIR=""
PLAN_ONLY=0
@@ -18,7 +18,7 @@ then validate the produced receipt, nextest listing, JUnit, and case oracle.
Options:
--case CASE Registry case to run (default: background-target-crash)
--profile PROFILE Nextest profile to use (default: e2e-nightly)
--profile PROFILE Nextest profile to use (default: registry lane)
--run-dir DIR New evidence directory (default: target/scanner-heal-evidence/CASE-TIMESTAMP)
--plan-only Validate registry selection and print the exact filter without running cargo
--self-test Run lightweight CLI/registry checks without building Rust
@@ -31,6 +31,18 @@ port allocator when the default 20000..30000 test range is unavailable.
USAGE
}
case_ids() {
"$PYTHON_BIN" - "$ROOT/.config/scanner-heal-required-tests.json" <<'PY'
import json
import pathlib
import sys
registry = json.loads(pathlib.Path(sys.argv[1]).read_text())
for case_id in sorted(registry["cases"]):
print(case_id)
PY
}
case_field() {
local case_id="$1"
local field="$2"
@@ -62,6 +74,40 @@ print("test(/^" + re.escape(case["name"]) + "$/)")
PY
}
runtime_profile_for() {
local case_id="$1"
case "$case_id" in
background-target-crash|background-target-restart)
echo "background-4x1"
;;
background-target-crash-ec8-4|background-target-restart-ec8-4)
echo "background-ec8-4"
;;
ec84-target-drive-restart)
echo "distributed-ec8-4"
;;
*)
echo "default"
;;
esac
}
apply_runtime_profile() {
local case_id="$1"
case "$(runtime_profile_for "$case_id")" in
background-4x1)
export RUSTFS_HEAL_CHAOS_OBJECT_COUNT="${RUSTFS_HEAL_CHAOS_OBJECT_COUNT:-64}"
export RUSTFS_HEAL_CHAOS_OBJECT_SIZE_BYTES="${RUSTFS_HEAL_CHAOS_OBJECT_SIZE_BYTES:-16777216}"
export RUSTFS_HEAL_CHAOS_PARTIAL_TIMEOUT_SECS="${RUSTFS_HEAL_CHAOS_PARTIAL_TIMEOUT_SECS:-120}"
;;
background-ec8-4)
export RUSTFS_HEAL_CHAOS_OBJECT_COUNT="${RUSTFS_HEAL_CHAOS_OBJECT_COUNT:-32}"
export RUSTFS_HEAL_CHAOS_OBJECT_SIZE_BYTES="${RUSTFS_HEAL_CHAOS_OBJECT_SIZE_BYTES:-8388608}"
export RUSTFS_HEAL_CHAOS_PARTIAL_TIMEOUT_SECS="${RUSTFS_HEAL_CHAOS_PARTIAL_TIMEOUT_SECS:-180}"
;;
esac
}
test_binary_from_listing() {
local listing="$1"
local case_id="$2"
@@ -109,20 +155,26 @@ PY
}
run_self_test() {
local filter
filter="$(test_filter_for background-target-crash)"
case "$filter" in
*background_target_crash*) ;;
*)
echo "self-test failed: crash case filter missing" >&2
return 1
;;
esac
if "$0" --case release --plan-only >/dev/null 2>&1; then
echo "self-test failed: release pseudo-case must not be runnable" >&2
return 1
fi
"$0" --case background-target-crash --plan-only >/dev/null
local case_id
while IFS= read -r case_id; do
local expected_filter expected_profile plan
expected_filter="$(test_filter_for "$case_id")"
expected_profile="$(case_field "$case_id" lane)"
expected_runtime_profile="$(runtime_profile_for "$case_id")"
plan="$("$0" --case "$case_id" --plan-only)"
if [[ "$plan" != *"case=$case_id"* ]] ||
[[ "$plan" != *"profile=$expected_profile"* ]] ||
[[ "$plan" != *"runtime_profile=$expected_runtime_profile"* ]] ||
[[ "$plan" != *"filter=$expected_filter"* ]] ||
[[ "$plan" != *"run_dir=$ROOT/target/scanner-heal-evidence/$case_id-"* ]]; then
echo "self-test failed: registry case plan mismatch for $case_id" >&2
return 1
fi
done < <(case_ids)
}
while [[ $# -gt 0 ]]; do
@@ -166,13 +218,10 @@ fi
case_field "$CASE_ID" name >/dev/null
TEST_FILTER="$(test_filter_for "$CASE_ID")"
case "$CASE_ID" in
background-target-crash|background-target-restart)
export RUSTFS_HEAL_CHAOS_OBJECT_COUNT="${RUSTFS_HEAL_CHAOS_OBJECT_COUNT:-64}"
export RUSTFS_HEAL_CHAOS_OBJECT_SIZE_BYTES="${RUSTFS_HEAL_CHAOS_OBJECT_SIZE_BYTES:-16777216}"
export RUSTFS_HEAL_CHAOS_PARTIAL_TIMEOUT_SECS="${RUSTFS_HEAL_CHAOS_PARTIAL_TIMEOUT_SECS:-120}"
;;
esac
if [[ -z "$PROFILE" ]]; then
PROFILE="$(case_field "$CASE_ID" lane)"
fi
apply_runtime_profile "$CASE_ID"
if [[ -z "$RUN_DIR" ]]; then
RUN_DIR="$ROOT/target/scanner-heal-evidence/${CASE_ID}-$(date -u +%Y%m%dT%H%M%SZ)"
elif [[ "$RUN_DIR" != /* ]]; then
@@ -182,6 +231,7 @@ fi
if [[ "$PLAN_ONLY" == 1 ]]; then
echo "case=$CASE_ID"
echo "profile=$PROFILE"
echo "runtime_profile=$(runtime_profile_for "$CASE_ID")"
echo "filter=$TEST_FILTER"
echo "run_dir=$RUN_DIR"
exit 0
+24 -16
View File
@@ -35,6 +35,7 @@ RELEASE_PROFILE_ARTIFACTS = (
"rss-samples",
"save-frequency",
)
MIN_MEASURED_RELEASE_DURATION_SECONDS = 7200
RELEASE_FAULT_MODES = (
"process-restart",
"process-crash-restart",
@@ -130,7 +131,7 @@ def validate_manifest(manifest):
number(fixed.get("offered_load_ops"), "offered load", 1)
require(type(manifest.get("rounds")) is int and 3 <= manifest["rounds"] <= 10,
"rounds must be 3..10")
minimum = 900 if manifest["evidence"] == "measured" else 1
minimum = MIN_MEASURED_RELEASE_DURATION_SECONDS if manifest["evidence"] == "measured" else 1
require(type(manifest.get("duration_seconds")) is int and
minimum <= manifest["duration_seconds"] <= 86400, "invalid duration_seconds")
number(manifest.get("min_free_bytes"), "min_free_bytes", 1)
@@ -173,6 +174,18 @@ def release_evidence_true(value, name):
require(value is True, f"missing release_evidence.{name}")
def release_evidence_exact_strings(value, expected, name):
require(isinstance(value, list) and all(isinstance(item, str) and item.strip() for item in value),
f"invalid release_evidence.{name}")
observed = set(value)
require(len(observed) == len(value), f"duplicate release_evidence.{name}")
missing = sorted(set(expected) - observed)
require(not missing, f"missing release_evidence.{name}: {', '.join(missing)}")
unknown = sorted(observed - set(expected))
require(not unknown, f"unknown release_evidence.{name}: {', '.join(unknown)}")
return value
def validate_release_evidence_manifest(manifest):
if manifest["evidence"] != "measured":
return
@@ -210,17 +223,17 @@ def validate_release_evidence_manifest(manifest):
crash = evidence.get("crash_restart")
require(isinstance(crash, dict), "missing release_evidence.crash_restart")
fault_modes = crash.get("fault_modes")
require(
isinstance(fault_modes, list)
and all(mode in fault_modes for mode in RELEASE_FAULT_MODES)
and all(isinstance(mode, str) and mode.strip() for mode in fault_modes),
"missing release_evidence.crash_restart.fault_modes",
)
release_evidence_exact_strings(crash.get("fault_modes"), RELEASE_FAULT_MODES, "crash_restart.fault_modes")
release_evidence_true(crash.get("unclean_shutdown_marker"), "crash_restart.unclean_shutdown_marker")
mixed = evidence.get("mixed_version")
require(isinstance(mixed, dict), "missing release_evidence.mixed_version")
baseline_revision = manifest["baseline"]["revision"]
candidate_revision = manifest["candidate"]["revision"]
require(baseline_revision != candidate_revision,
"release_evidence.mixed_version requires distinct baseline and candidate revisions")
require(manifest["baseline"]["sha256"] != manifest["candidate"]["sha256"],
"release_evidence.mixed_version requires distinct baseline and candidate binaries")
revisions = mixed.get("participating_revisions")
require(
isinstance(revisions, list)
@@ -229,20 +242,15 @@ def validate_release_evidence_manifest(manifest):
for revision in revisions),
"invalid release_evidence.mixed_version.participating_revisions",
)
for revision in (manifest["baseline"]["revision"], manifest["candidate"]["revision"]):
for revision in (baseline_revision, candidate_revision):
require(revision in revisions, "release_evidence.mixed_version omits tested build revision")
for key in ("reader", "writer", "rollback_payload"):
require(mixed.get(key) is True, f"missing release_evidence.mixed_version.{key}")
profile = evidence.get("profile")
require(isinstance(profile, dict), "missing release_evidence.profile")
artifacts = profile.get("required_artifacts")
require(
isinstance(artifacts, list)
and all(item in artifacts for item in RELEASE_PROFILE_ARTIFACTS)
and all(isinstance(item, str) and item.strip() for item in artifacts),
"missing release_evidence.profile.required_artifacts",
)
release_evidence_exact_strings(profile.get("required_artifacts"), RELEASE_PROFILE_ARTIFACTS,
"profile.required_artifacts")
for key in ("collector_config_sha256", "profiler_config_sha256"):
require(sha(profile.get(key)), f"invalid release_evidence.profile.{key}")
+9 -1
View File
@@ -12,7 +12,12 @@ from pathlib import Path
import sys
from typing import Any
from scanner_abba import LEGS, SCENARIOS, validate_release_evidence_manifest
from scanner_abba import (
LEGS,
MIN_MEASURED_RELEASE_DURATION_SECONDS,
SCENARIOS,
validate_release_evidence_manifest,
)
MAX_JSON_BYTES = 1024 * 1024
CACHE_COST_PREFIX = "CACHE_COST "
@@ -124,6 +129,9 @@ def require_measured_comparison_evidence(comparison: dict[str, Any], index: int)
def require_complete_abba_matrix(manifest: dict[str, Any], report: dict[str, Any], comparisons: list[dict[str, Any]]) -> None:
require(report.get("evidence") == manifest.get("evidence"), "manifest/report evidence mismatch")
require(type(manifest.get("duration_seconds")) is int and
manifest["duration_seconds"] >= MIN_MEASURED_RELEASE_DURATION_SECONDS,
"measured ABBA duration_seconds requires at least two hours")
rounds = manifest.get("rounds")
require(type(rounds) is int and 3 <= rounds <= 10, "invalid manifest.rounds")
expected_cells = len(SCENARIOS) * 2 * rounds * len(LEGS)
+31 -3
View File
@@ -167,8 +167,15 @@ class ScannerAbbaTest(unittest.TestCase):
def measured_manifest(self):
manifest = copy.deepcopy(self.manifest)
manifest.update(evidence="measured", duration_seconds=900)
manifest["candidate"]["revision"] = "b" * 40
manifest.update(evidence="measured", duration_seconds=harness.MIN_MEASURED_RELEASE_DURATION_SECONDS)
candidate_binary = self.root / "candidate-python"
candidate_binary.write_bytes(self.binary.read_bytes() + b"\n")
candidate_binary.chmod(0o755)
manifest["candidate"] = {
"binary": str(candidate_binary),
"sha256": harness.digest(candidate_binary),
"revision": "b" * 40,
}
manifest["release_evidence"] = {
"topology": {
"nodes": 3,
@@ -527,7 +534,7 @@ class ScannerAbbaTest(unittest.TestCase):
self.manifest["evidence"] = "measured"
with self.assertRaisesRegex(ValueError, "duration_seconds"):
harness.validate_manifest(self.manifest)
self.manifest["duration_seconds"] = 900
self.manifest["duration_seconds"] = harness.MIN_MEASURED_RELEASE_DURATION_SECONDS
self.manifest["rounds"] = 2
with self.assertRaisesRegex(ValueError, "rounds"):
harness.validate_manifest(self.manifest)
@@ -548,6 +555,12 @@ class ScannerAbbaTest(unittest.TestCase):
"missing crash": lambda manifest: manifest["release_evidence"]["crash_restart"].update(
fault_modes=["process-restart"],
),
"unknown crash": lambda manifest: manifest["release_evidence"]["crash_restart"].update(
fault_modes=["process-restart", "process-crash-restart", "kernel-panic"],
),
"duplicate crash": lambda manifest: manifest["release_evidence"]["crash_restart"].update(
fault_modes=["process-restart", "process-restart", "process-crash-restart"],
),
"clean crash marker": lambda manifest: manifest["release_evidence"]["crash_restart"].update(
unclean_shutdown_marker=False,
),
@@ -555,9 +568,24 @@ class ScannerAbbaTest(unittest.TestCase):
"missing candidate": lambda manifest: manifest["release_evidence"]["mixed_version"].update(
participating_revisions=["a" * 40, "c" * 40],
),
"same mixed revision": lambda manifest: manifest["candidate"].update(
revision=manifest["baseline"]["revision"],
),
"same mixed binary": lambda manifest: manifest["candidate"].update(
binary=manifest["baseline"]["binary"],
sha256=manifest["baseline"]["sha256"],
),
"missing profile": lambda manifest: manifest["release_evidence"]["profile"].update(
required_artifacts=["allocation-profile", "flamegraph", "rss-samples"],
),
"unknown profile": lambda manifest: manifest["release_evidence"]["profile"].update(
required_artifacts=["allocation-profile", "flamegraph", "rss-samples", "save-frequency", "heapdump"],
),
"duplicate profile": lambda manifest: manifest["release_evidence"]["profile"].update(
required_artifacts=[
"allocation-profile", "flamegraph", "rss-samples", "save-frequency", "flamegraph",
],
),
"bad profile hash": lambda manifest: manifest["release_evidence"]["profile"].update(
profiler_config_sha256="not-a-sha",
),
@@ -37,6 +37,7 @@ class ScannerHealPerfSummaryTest(unittest.TestCase):
"schema": 1,
"evidence": "measured",
"rounds": 3,
"duration_seconds": summary.MIN_MEASURED_RELEASE_DURATION_SECONDS,
"fixed": {
"config_sha256": "1" * 64,
"dataset_sha256": "2" * 64,
@@ -225,6 +226,19 @@ class ScannerHealPerfSummaryTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "ABBA matrix|manifest/report evidence|comparison"):
summary.build_summary(args)
def test_passing_measured_report_requires_two_hour_window(self):
self.manifest["duration_seconds"] = summary.MIN_MEASURED_RELEASE_DURATION_SECONDS - 1
self.write_inputs()
args = type("Args", (), {
"abba_dir": self.abba,
"cache_cost_log": None,
"require_cache_cost": False,
"json_out": None,
"markdown_out": None,
})
with self.assertRaisesRegex(ValueError, "two hours"):
summary.build_summary(args)
def test_passing_abba_report_requires_w10_w11_evidence(self):
for fault in ("missing", "pressure", "lock", "attempt", "length", "range"):
with self.subTest(fault=fault):