fix(heal): reject incomplete migration lineage before staging

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
This commit is contained in:
houseme
2026-09-05 17:03:53 +08:00
parent 408ee1be22
commit d361b3cfac
2 changed files with 330 additions and 55 deletions
@@ -29,6 +29,7 @@ const CLAIM: &str = ".heal-mrf-import-claim.bin";
pub struct MigrationLimits { pub struct MigrationLimits {
pub max_bytes: usize, pub max_bytes: usize,
pub max_records: usize, pub max_records: usize,
pub max_sources: usize,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -49,7 +50,7 @@ pub enum MigrationError {
Claimed, Claimed,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
enum LegacyPath { enum LegacyPath {
Scoped, Scoped,
Mirror, Mirror,
@@ -107,9 +108,6 @@ impl PendingMigration {
offset = end; offset = end;
} }
} }
if records.is_empty() {
return Err(MigrationError::Empty);
}
Ok(records.into_iter().collect()) Ok(records.into_iter().collect())
} }
@@ -120,6 +118,14 @@ impl PendingMigration {
if self.sources.is_empty() || self.sources.len() > MAX_DISKS * 2 { if self.sources.is_empty() || self.sources.len() > MAX_DISKS * 2 {
return Err(MigrationError::Invalid); return Err(MigrationError::Invalid);
} }
if self
.sources
.len()
.checked_add(self.inherited.len())
.is_none_or(|count| count > limits.max_sources)
{
return Err(SnapshotError::TooLarge.into());
}
// Bound the raw input before allocating the JSON representation. // Bound the raw input before allocating the JSON representation.
let total = self let total = self
.sources .sources
@@ -247,14 +253,21 @@ struct Staged {
slot: usize, slot: usize,
} }
async fn read_staged(disks: &[EcstoreDiskStore], limits: MigrationLimits) -> Result<Option<Staged>, MigrationError> { async fn read_staged(
disks: &[EcstoreDiskStore],
limits: MigrationLimits,
retry_payload: Option<&[u8]>,
) -> Result<Option<Staged>, MigrationError> {
let mut selected: Option<Staged> = None; let mut selected: Option<Staged> = None;
let mut damage = None;
let mut identities = std::collections::BTreeMap::new(); let mut identities = std::collections::BTreeMap::new();
let mut orphaned_payloads = Vec::new();
for disk in disks { for disk in disks {
for slot in 0..2 { for slot in 0..2 {
let result = async { let result = async {
let Some(bytes) = read_bounded(disk, COMMITS[slot], MANIFEST_LEN).await? else { let Some(bytes) = read_bounded(disk, COMMITS[slot], MANIFEST_LEN).await? else {
if let Some(payload) = read_bounded(disk, PAYLOADS[slot], limits.max_bytes).await? {
orphaned_payloads.push((payload.len(), <[u8; 32]>::from(Sha256::digest(&payload))));
}
return Ok(None); return Ok(None);
}; };
let manifest = Manifest::decode(&bytes, limits.max_bytes)?; let manifest = Manifest::decode(&bytes, limits.max_bytes)?;
@@ -266,7 +279,7 @@ async fn read_staged(disks: &[EcstoreDiskStore], limits: MigrationLimits) -> Res
return Err(MigrationError::Invalid); return Err(MigrationError::Invalid);
} }
let candidate: PendingMigration = serde_json::from_slice(&payload).map_err(|_| MigrationError::Invalid)?; let candidate: PendingMigration = serde_json::from_slice(&payload).map_err(|_| MigrationError::Invalid)?;
if candidate.encode(limits)? != payload { if candidate.encode(limits)? != payload || candidate.replay_records(limits)?.is_empty() {
return Err(MigrationError::Invalid); return Err(MigrationError::Invalid);
} }
Ok(Some(Staged { Ok(Some(Staged {
@@ -293,16 +306,24 @@ async fn read_staged(disks: &[EcstoreDiskStore], limits: MigrationLimits) -> Res
} }
} }
Ok(None) => {} Ok(None) => {}
Err(MigrationError::Snapshot(SnapshotError::Unsupported)) => return Err(SnapshotError::Unsupported.into()), Err(error) => return Err(error),
Err(error) => damage = Some(error),
} }
} }
} }
match (selected, damage) { // A missing manifest is repairable only for the exact retry candidate or
(Some(staged), _) => Ok(Some(staged)), // payload already validated through another committed replica. Unknown
(None, Some(error)) => Err(error), // candidate bytes may contain additional obligations and block selection.
(None, None) => Ok(None), let retry_identity = retry_payload.map(|payload| (payload.len(), <[u8; 32]>::from(Sha256::digest(payload))));
let committed_identity = selected
.as_ref()
.map(|staged| (staged.manifest.payload_len, staged.manifest.payload_digest));
if orphaned_payloads
.into_iter()
.any(|identity| Some(identity) != retry_identity && Some(identity) != committed_identity)
{
return Err(MigrationError::Conflict);
} }
Ok(selected)
} }
async fn install(disk: &EcstoreDiskStore, path: &str, bytes: &[u8], limit: usize) -> Result<(), MigrationError> { async fn install(disk: &EcstoreDiskStore, path: &str, bytes: &[u8], limit: usize) -> Result<(), MigrationError> {
@@ -349,14 +370,29 @@ pub async fn stage_legacy_migration(
stage_claimed(&disks, candidate, owner, limits).await stage_claimed(&disks, candidate, owner, limits).await
} }
.await; .await;
let mut release_error = None;
for disk in claimed { for disk in claimed {
let released = let release = async {
EcstoreDiskAPI::compare_and_update_file(disk.as_ref(), RUSTFS_META_BUCKET, CLAIM, Some(claim.clone()), None) #[cfg(test)]
.await tests::interrupt_at(owner, tests::Boundary::Release).await?;
.map_err(SnapshotError::Disk)?; let released =
if released != EcstoreConditionalFileUpdate::Updated { EcstoreDiskAPI::compare_and_update_file(disk.as_ref(), RUSTFS_META_BUCKET, CLAIM, Some(claim.clone()), None)
return Err(MigrationError::Claimed); .await
.map_err(SnapshotError::Disk)?;
if released != EcstoreConditionalFileUpdate::Updated {
return Err(MigrationError::Claimed);
}
Ok(())
} }
.await;
if let Err(error) = release
&& release_error.is_none()
{
release_error = Some(error);
}
}
if let Some(error) = release_error {
return Err(error);
} }
result result
} }
@@ -367,36 +403,59 @@ async fn stage_claimed(
owner: Uuid, owner: Uuid,
limits: MigrationLimits, limits: MigrationLimits,
) -> Result<u64, MigrationError> { ) -> Result<u64, MigrationError> {
candidate.revalidate(&disks, limits).await?; candidate.revalidate(disks, limits).await?;
let previous = read_staged(&disks, limits).await?; let captured_payload = candidate.encode(limits)?;
let previous = read_staged(disks, limits, Some(&captured_payload)).await?;
if previous.as_ref().is_some_and(|old| old.manifest.owner != owner) { if previous.as_ref().is_some_and(|old| old.manifest.owner != owner) {
return Err(MigrationError::Conflict); return Err(MigrationError::Conflict);
} }
let mut candidate = candidate.clone(); let mut candidate = candidate.clone();
let key = |source: &Source| (source.disk_id, source.path, source.digest, source.bytes.is_some());
let mut source_index = std::collections::HashMap::new();
for (index, source) in candidate.sources.iter().chain(&candidate.inherited).enumerate() {
if source_index.insert(key(source), index).is_some() {
return Err(MigrationError::Invalid);
}
}
if let Some(old) = &previous { if let Some(old) = &previous {
for source in old.candidate.sources.iter().chain(&old.candidate.inherited) { for source in old.candidate.sources.iter().chain(&old.candidate.inherited) {
if !candidate.sources.contains(source) && !candidate.inherited.contains(source) { if let Some(index) = source_index.get(&key(source)).copied() {
let existing = if index < candidate.sources.len() {
&candidate.sources[index]
} else {
&candidate.inherited[index - candidate.sources.len()]
};
if existing != source {
return Err(MigrationError::Conflict);
}
} else {
if source_index.len() >= limits.max_sources {
return Err(SnapshotError::TooLarge.into());
}
source_index.insert(key(source), candidate.sources.len() + candidate.inherited.len());
candidate.inherited.push(source.clone()); candidate.inherited.push(source.clone());
} }
} }
} }
if candidate.replay_records(limits)?.is_empty() {
return Err(MigrationError::Empty);
}
let payload = candidate.encode(limits)?; let payload = candidate.encode(limits)?;
let digest: [u8; 32] = Sha256::digest(&payload).into(); let digest: [u8; 32] = Sha256::digest(&payload).into();
if let Some(old) = &previous let (sequence, slot) = previous.as_ref().map_or((1, 0), |old| {
&& old.manifest.payload_digest == digest if old.manifest.payload_digest == digest {
{ (old.manifest.sequence, old.slot)
candidate.revalidate(&disks, limits).await?; } else {
return Ok(old.manifest.sequence); (old.manifest.sequence.saturating_add(1), 1 - old.slot)
} }
let sequence = previous.as_ref().map_or(1, |old| old.manifest.sequence.saturating_add(1)); });
let slot = previous.as_ref().map_or(0, |old| 1 - old.slot);
let manifest = Manifest::encode(owner, sequence, &payload)?; let manifest = Manifest::encode(owner, sequence, &payload)?;
for disk in disks { for disk in disks {
install(disk, PAYLOADS[slot], &payload, limits.max_bytes).await?; install(disk, PAYLOADS[slot], &payload, limits.max_bytes).await?;
} }
#[cfg(test)] #[cfg(test)]
tests::interrupt_at(owner, tests::Boundary::AfterPayload).await?; tests::interrupt_at(owner, tests::Boundary::AfterPayload).await?;
candidate.revalidate(&disks, limits).await?; candidate.revalidate(disks, limits).await?;
#[cfg(test)] #[cfg(test)]
tests::interrupt_at(owner, tests::Boundary::BeforeManifest).await?; tests::interrupt_at(owner, tests::Boundary::BeforeManifest).await?;
for disk in disks { for disk in disks {
@@ -404,11 +463,11 @@ async fn stage_claimed(
} }
#[cfg(test)] #[cfg(test)]
tests::interrupt_at(owner, tests::Boundary::AfterManifest).await?; tests::interrupt_at(owner, tests::Boundary::AfterManifest).await?;
let recovered = read_staged(&disks, limits).await?.ok_or(MigrationError::Invalid)?; let recovered = read_staged(disks, limits, None).await?.ok_or(MigrationError::Invalid)?;
if recovered.manifest.sequence != sequence || recovered.manifest.payload_digest != digest { if recovered.manifest.sequence != sequence || recovered.manifest.payload_digest != digest {
return Err(MigrationError::Conflict); return Err(MigrationError::Conflict);
} }
recovered.candidate.revalidate(&disks, limits).await?; recovered.candidate.revalidate(disks, limits).await?;
#[cfg(test)] #[cfg(test)]
tests::interrupt_at(owner, tests::Boundary::AfterReadback).await?; tests::interrupt_at(owner, tests::Boundary::AfterReadback).await?;
Ok(sequence) Ok(sequence)
@@ -421,7 +480,7 @@ pub async fn recover_pending_migration(
limits: MigrationLimits, limits: MigrationLimits,
) -> Result<Option<PendingMigration>, MigrationError> { ) -> Result<Option<PendingMigration>, MigrationError> {
let disks = configured_disks(disks).await?; let disks = configured_disks(disks).await?;
let Some(staged) = read_staged(&disks, limits).await? else { let Some(staged) = read_staged(&disks, limits, None).await? else {
return Ok(None); return Ok(None);
}; };
staged.candidate.revalidate(&disks, limits).await?; staged.candidate.revalidate(&disks, limits).await?;
@@ -440,6 +499,7 @@ mod tests {
const LIMITS: MigrationLimits = MigrationLimits { const LIMITS: MigrationLimits = MigrationLimits {
max_bytes: 64 * 1024, max_bytes: 64 * 1024,
max_records: 100, max_records: 100,
max_sources: 64,
}; };
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@@ -448,6 +508,7 @@ mod tests {
BeforeManifest, BeforeManifest,
AfterManifest, AfterManifest,
AfterReadback, AfterReadback,
Release,
} }
static INTERRUPTIONS: std::sync::LazyLock<std::sync::Mutex<std::collections::BTreeMap<Uuid, Boundary>>> = static INTERRUPTIONS: std::sync::LazyLock<std::sync::Mutex<std::collections::BTreeMap<Uuid, Boundary>>> =
@@ -698,6 +759,7 @@ mod tests {
stage_legacy_migration(&disks, &candidate, Uuid::new_v4(), LIMITS) stage_legacy_migration(&disks, &candidate, Uuid::new_v4(), LIMITS)
.await .await
.expect("initial commit"); .expect("initial commit");
let previous = read_bounded(&disk, PAYLOADS[0], LIMITS.max_bytes).await.expect("old payload");
for (path, bytes) in [ for (path, bytes) in [
(PAYLOADS[1], b"torn payload".as_slice()), (PAYLOADS[1], b"torn payload".as_slice()),
(COMMITS[1], b"torn manifest".as_slice()), (COMMITS[1], b"torn manifest".as_slice()),
@@ -705,16 +767,11 @@ mod tests {
install(&disk, path, bytes, LIMITS.max_bytes) install(&disk, path, bytes, LIMITS.max_bytes)
.await .await
.expect("interrupted inactive write"); .expect("interrupted inactive write");
assert_eq!( assert!(
recover_pending_migration(&disks, LIMITS) recover_pending_migration(&disks, LIMITS).await.is_err(),
.await "unknown successor responsibility must block recovery"
.expect("recover previous")
.expect("anchor")
.replay_records(LIMITS)
.expect("retained responsibility")
.len(),
1
); );
assert_eq!(read_bounded(&disk, PAYLOADS[0], LIMITS.max_bytes).await.expect("old anchor"), previous);
} }
} }
@@ -726,8 +783,11 @@ mod tests {
capture_legacy_migration(&[Some(disk.clone()), None], LIMITS).await, capture_legacy_migration(&[Some(disk.clone()), None], LIMITS).await,
Err(MigrationError::CoverageGap) Err(MigrationError::CoverageGap)
)); ));
let empty = capture_legacy_migration(&[Some(disk.clone())], LIMITS)
.await
.expect("observed empty sources");
assert!(matches!( assert!(matches!(
capture_legacy_migration(&[Some(disk.clone())], LIMITS).await, stage_legacy_migration(&[Some(disk.clone())], &empty, Uuid::new_v4(), LIMITS).await,
Err(MigrationError::Empty) Err(MigrationError::Empty)
)); ));
source(&disk, b"corrupt").await; source(&disk, b"corrupt").await;
@@ -760,8 +820,15 @@ mod tests {
.expect("source survives interruption"), .expect("source survives interruption"),
bytes bytes
); );
let recovery = recover_pending_migration(&disks, LIMITS).await.expect("restart inspection"); let recovery = recover_pending_migration(&disks, LIMITS).await;
assert_eq!(recovery.is_some(), matches!(boundary, Boundary::AfterManifest | Boundary::AfterReadback)); if matches!(boundary, Boundary::AfterManifest | Boundary::AfterReadback) {
assert!(recovery.expect("committed restart").is_some());
} else {
assert!(
matches!(recovery, Err(MigrationError::Conflict)),
"uncommitted candidate is not a committed recovery result"
);
}
assert_eq!( assert_eq!(
stage_legacy_migration(&disks, &candidate, owner, LIMITS) stage_legacy_migration(&disks, &candidate, owner, LIMITS)
.await .await
@@ -833,12 +900,7 @@ mod tests {
stage_legacy_migration(&disks, &candidate, owner, LIMITS).await, stage_legacy_migration(&disks, &candidate, owner, LIMITS).await,
Err(MigrationError::SourceChanged) Err(MigrationError::SourceChanged)
)); ));
assert!( assert!(matches!(recover_pending_migration(&disks, LIMITS).await, Err(MigrationError::Conflict)));
recover_pending_migration(&disks, LIMITS)
.await
.expect("no committed import")
.is_none()
);
assert_eq!( assert_eq!(
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH) EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
.await .await
@@ -897,4 +959,217 @@ mod tests {
assert!(records.contains(&variant)); assert!(records.contains(&variant));
} }
} }
async fn slot_bytes(disk: &EcstoreDiskStore) -> Vec<Option<Vec<u8>>> {
let mut bytes = Vec::new();
for path in PAYLOADS.into_iter().chain(COMMITS) {
bytes.push(read_bounded(disk, path, LIMITS.max_bytes).await.expect("slot bytes"));
}
bytes
}
#[tokio::test]
async fn migration_damaged_newer_commit_never_overwrites_successor_responsibility() {
let root = TempDir::new().expect("test directory");
let disk = disk(&root, "disk").await;
let disks = [Some(disk.clone())];
let owner = Uuid::new_v4();
for name in ["a", "b"] {
source(&disk, &record(name, MrfKind::PartialWrite, None)).await;
let candidate = capture_legacy_migration(&disks, LIMITS).await.expect("capture");
stage_legacy_migration(&disks, &candidate, owner, LIMITS)
.await
.expect("committed generation");
}
install(&disk, COMMITS[1], b"torn higher manifest", MANIFEST_LEN)
.await
.expect("manifest fault");
source(&disk, &record("c", MrfKind::PartialWrite, None)).await;
let before = slot_bytes(&disk).await;
let candidate = capture_legacy_migration(&disks, LIMITS).await.expect("latest legacy source");
assert!(stage_legacy_migration(&disks, &candidate, owner, LIMITS).await.is_err());
assert_eq!(slot_bytes(&disk).await, before, "the only payload containing b must not be overwritten");
}
#[tokio::test]
async fn migration_lower_limits_never_fall_back_to_a_smaller_old_generation() {
let root = TempDir::new().expect("test directory");
let disk = disk(&root, "disk").await;
let disks = [Some(disk.clone())];
let owner = Uuid::new_v4();
for name in ["a", "b"] {
source(&disk, &record(name, MrfKind::PartialWrite, None)).await;
let candidate = capture_legacy_migration(&disks, LIMITS).await.expect("capture");
stage_legacy_migration(&disks, &candidate, owner, LIMITS)
.await
.expect("committed generation");
}
source(&disk, &record("a", MrfKind::PartialWrite, None)).await;
let before = slot_bytes(&disk).await;
let first_len = before[0].as_ref().expect("first payload").len();
for limits in [
MigrationLimits {
max_bytes: first_len,
..LIMITS
},
MigrationLimits {
max_records: 1,
..LIMITS
},
MigrationLimits {
max_sources: 2,
..LIMITS
},
] {
assert!(matches!(
recover_pending_migration(&disks, limits).await,
Err(MigrationError::Snapshot(SnapshotError::TooLarge))
));
assert_eq!(slot_bytes(&disk).await, before);
}
}
#[tokio::test]
async fn migration_source_history_count_is_bounded_before_successor_write() {
let root = TempDir::new().expect("test directory");
let disk = disk(&root, "disk").await;
let disks = [Some(disk.clone())];
let owner = Uuid::new_v4();
let limits = MigrationLimits {
max_sources: 3,
..LIMITS
};
let mut records = (0..10)
.map(|n| record(&n.to_string(), MrfKind::PartialWrite, None))
.collect::<Vec<_>>();
for round in 0..3 {
records.rotate_left(1);
source(&disk, &records.concat()).await;
let candidate = capture_legacy_migration(&disks, limits)
.await
.expect("bounded current sources");
assert_eq!(candidate.replay_records(limits).expect("same responsibilities").len(), 10);
let before = slot_bytes(&disk).await;
let result = stage_legacy_migration(&disks, &candidate, owner, limits).await;
if round < 2 {
assert_eq!(result.expect("within source count"), round + 1);
} else {
assert!(matches!(result, Err(MigrationError::Snapshot(SnapshotError::TooLarge))));
assert_eq!(slot_bytes(&disk).await, before);
}
}
}
#[tokio::test]
async fn migration_empty_legacy_sources_still_inherit_prior_responsibilities() {
let root = TempDir::new().expect("test directory");
let disk = disk(&root, "disk").await;
let disks = [Some(disk.clone())];
let owner = Uuid::new_v4();
let bytes = record("a", MrfKind::PartialWrite, None);
source(&disk, &bytes).await;
let candidate = capture_legacy_migration(&disks, LIMITS).await.expect("initial source");
stage_legacy_migration(&disks, &candidate, owner, LIMITS)
.await
.expect("initial stage");
EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
MRF_SCOPED_JOURNAL_PATH,
Some(bytes.clone().into()),
None,
)
.await
.expect("legacy removes source");
let empty = capture_legacy_migration(&disks, LIMITS)
.await
.expect("valid absent-source observation");
assert!(empty.replay_records(LIMITS).expect("empty observation").is_empty());
assert_eq!(
stage_legacy_migration(&disks, &empty, owner, LIMITS)
.await
.expect("inherit earlier obligation"),
2
);
assert_eq!(
recover_pending_migration(&disks, LIMITS)
.await
.expect("restart")
.expect("pending")
.replay_records(LIMITS)
.expect("inherited responsibility"),
vec![bytes]
);
}
#[tokio::test]
async fn migration_release_failure_still_releases_other_owned_claims() {
let root = TempDir::new().expect("test directory");
let first = disk(&root, "first").await;
let second = disk(&root, "second").await;
source(&first, &record("a", MrfKind::PartialWrite, None)).await;
source(&second, &record("a", MrfKind::PartialWrite, None)).await;
let disks = [Some(first), Some(second)];
let ordered = configured_disks(&disks).await.expect("disk order");
let owner = Uuid::new_v4();
let candidate = capture_legacy_migration(&disks, LIMITS).await.expect("capture");
INTERRUPTIONS.lock().expect("fault map").insert(owner, Boundary::Release);
assert!(stage_legacy_migration(&disks, &candidate, owner, LIMITS).await.is_err());
assert!(
read_bounded(&ordered[0], CLAIM, 64)
.await
.expect("failed release remains claimed")
.is_some()
);
assert!(
read_bounded(&ordered[1], CLAIM, 64)
.await
.expect("later release attempted")
.is_none()
);
}
#[tokio::test]
async fn migration_retry_repairs_missing_manifest_replica() {
let root = TempDir::new().expect("test directory");
let first = disk(&root, "first").await;
let second = disk(&root, "second").await;
let disks = [Some(first.clone()), Some(second.clone())];
let owner = Uuid::new_v4();
for name in ["a", "b"] {
let bytes = record(name, MrfKind::PartialWrite, None);
source(&first, &bytes).await;
source(&second, &bytes).await;
let candidate = capture_legacy_migration(&disks, LIMITS).await.expect("capture generation");
stage_legacy_migration(&disks, &candidate, owner, LIMITS)
.await
.expect("stage generation");
}
let candidate = capture_legacy_migration(&disks, LIMITS).await.expect("capture");
let committed = read_bounded(&second, COMMITS[1], MANIFEST_LEN)
.await
.expect("manifest")
.expect("committed");
EcstoreDiskAPI::compare_and_update_file(
second.as_ref(),
RUSTFS_META_BUCKET,
COMMITS[1],
Some(committed.clone().into()),
None,
)
.await
.expect("lost replica");
assert_eq!(
stage_legacy_migration(&disks, &candidate, owner, LIMITS)
.await
.expect("retry missing replica"),
2
);
assert_eq!(
read_bounded(&second, COMMITS[1], MANIFEST_LEN)
.await
.expect("repaired manifest"),
Some(committed)
);
}
} }
+3 -3
View File
@@ -2,13 +2,13 @@
`heal::mrf_queue::snapshot::migration` exposes explicit capture, staging, and readback of pending legacy responsibility evidence. Nothing invokes it from the production MRF consumer. It does not enable the committed-snapshot writer, freeze legacy ingress, acknowledge durable admission, or authorize source garbage collection. `heal::mrf_queue::snapshot::migration` exposes explicit capture, staging, and readback of pending legacy responsibility evidence. Nothing invokes it from the production MRF consumer. It does not enable the committed-snapshot writer, freeze legacy ingress, acknowledge durable admission, or authorize source garbage collection.
The caller supplies every configured local disk slot, including missing slots. Missing/unformatted/duplicate disks, unavailable metadata volumes, invalid records, empty evidence, and aggregate byte/record overflow fail closed. Both legacy paths retain their original bytes, disk identity, absent-versus-empty state, and SHA-256 digest. Complete subset/superset replicas become conservative pending evidence, never a claimed newest legacy snapshot. Raw record replay preserves kind, scope and nil/absent version encodings; unknown incarnation stays unknown. The caller supplies every configured local disk slot, including missing slots. Missing/unformatted/duplicate disks, unavailable metadata volumes, invalid records, and aggregate byte/record/source-history overflow fail closed. A valid empty source observation can inherit earlier pending responsibilities, but staging without any current or inherited responsibility is rejected. Both legacy paths retain their original bytes, disk identity, absent-versus-empty state, and SHA-256 digest. Complete subset/superset replicas become conservative pending evidence, never a claimed newest legacy snapshot. Raw record replay preserves kind, scope and nil/absent version encodings; unknown incarnation stays unknown.
Staging writes only `.heal-mrf-import-pending.{0,1}.bin`, `.heal-mrf-import-commit.{0,1}.bin`, and `.heal-mrf-import-claim.bin` under the metadata volume. It reuses the committed reader's manifest codec and the storage owner's conditional-file operation, including the configured metadata durability policy. A candidate is written before sources are revalidated, its manifest is then committed, and committed bytes plus source coverage are read back before success. Success is pending staging evidence, not a power-loss or cluster-quorum durability receipt. Staging writes only `.heal-mrf-import-pending.{0,1}.bin`, `.heal-mrf-import-commit.{0,1}.bin`, and `.heal-mrf-import-claim.bin` under the metadata volume. It reuses the committed reader's manifest codec and the storage owner's conditional-file operation, including the configured metadata durability policy. A candidate is written before sources are revalidated, its manifest is then committed, and committed bytes plus source coverage are read back before success. Success is pending staging evidence, not a power-loss or cluster-quorum durability receipt.
A successor inherits prior source bytes even if a replay consumer has already read or admitted their records. Size limits include inherited evidence. There is no completion-based pruning. A changed source blocks recovery of that pending generation; an explicit new capture can stage a successor that retains both the previous and current responsibilities. The inactive slot is replaced while the preceding committed slot remains intact. A successor inherits prior source bytes even if a replay consumer has already read or admitted their records. Independent byte, record, and source-history limits include inherited evidence, and source identities use a hash index with full-byte conflict checks. There is no completion-based pruning. A changed source blocks recovery of that pending generation; an explicit new capture can stage a successor that retains both the previous and current responsibilities. The inactive slot is replaced while the preceding committed slot remains intact. Any corrupt, unsupported or over-budget slot blocks selection rather than falling back to an older generation. A payload without a manifest is repairable only when its length and digest match the exact retry candidate or a payload independently validated through another committed replica; retries also repair missing replica manifests.
All participating disks are claimed in disk-identity order through CAS. Normal completion conditionally releases only the current invocation's claim. Cancellation, process death, or an ambiguous claim/release I/O failure may leave a claim behind. Read-only recovery remains available, but further staging is blocked until a separate storage-fenced recovery procedure is implemented. Process liveness and the legacy ingress lease do not authorize taking over or deleting a claim. All participating disks are claimed in disk-identity order through CAS. Normal completion conditionally releases only the current invocation's claim, attempts every acquired claim even after a release error, and reports the first release error. Cancellation, process death, or an ambiguous claim/release I/O failure may leave a claim behind. Read-only recovery remains available when the snapshot/source proof is valid, but further staging is blocked until a separate storage-fenced recovery procedure is implemented. Process liveness and the legacy ingress lease do not authorize taking over or deleting a claim.
Run the focused fixtures with a nonzero test count: Run the focused fixtures with a nonzero test count: