diff --git a/crates/ecstore/src/disk/local.rs b/crates/ecstore/src/disk/local.rs index c86e85381..1073301ed 100644 --- a/crates/ecstore/src/disk/local.rs +++ b/crates/ecstore/src/disk/local.rs @@ -22543,6 +22543,62 @@ mod test { ); } + #[cfg(unix)] + #[tokio::test] + async fn conditional_mrf_manifest_storage_full_keeps_recovery_anchors() { + use tempfile::tempdir; + + const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.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"); + os::fsync_dir_recorder::set_failure(parent, ErrorKind::StorageFull); + + 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("storage-full fsync failure must fail the MRF manifest successor commit"); + assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::StorageFull)); + assert_eq!( + disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0) + .await + .expect("previous committed MRF manifest should remain readable after storage-full rollback"), + previous_manifest + ); + assert_eq!( + disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH) + .await + .expect("legacy MRF journal should remain readable after storage-full manifest publication failure"), + legacy_journal + ); + } + #[cfg(unix)] #[tokio::test] async fn conditional_file_update_dir_fsync_failure_removes_new_file_without_anchor() { diff --git a/crates/heal/src/heal/mrf_queue/snapshot.rs b/crates/heal/src/heal/mrf_queue/snapshot.rs index c00141f27..6164bd2c3 100644 --- a/crates/heal/src/heal/mrf_queue/snapshot.rs +++ b/crates/heal/src/heal/mrf_queue/snapshot.rs @@ -1140,6 +1140,54 @@ mod tests { ); } + #[tokio::test] + async fn committed_snapshot_writer_payload_failure_does_not_publish_manifest() { + 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; + std::fs::create_dir(root.path().join("disk").join(RUSTFS_META_BUCKET).join(PAYLOAD_PATHS[1])) + .expect("payload path blocks successor staging"); + + let result = publish_committed_snapshot(std::slice::from_ref(&store), owner, 2, &next, 4096).await; + + assert!( + matches!(result, Err(SnapshotError::Disk(_) | SnapshotError::Read(_))), + "payload failure must be visible before manifest publication: {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, PAYLOAD_PATHS[0]) + .await + .expect("old payload retained") + .as_ref(), + 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, MANIFEST_PATHS[1]).await, + Err(EcstoreDiskError::FileNotFound | EcstoreDiskError::VolumeNotFound) + ), + "successor manifest must not be published when payload staging fails" + ); + } + #[tokio::test] async fn committed_snapshot_writer_capacity_failure_preserves_previous_anchor() { let root = TempDir::new().expect("test directory");