mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-04 19:25:40 +00:00
fix(storage): resolve erasure parity per pool (#4977)
* fix(filemeta): add state-aware file info validation
* fix(filemeta): validate shard arithmetic and delete paths
* fix(ecstore): add fallible erasure construction
* fix(ecstore): resolve storage parity per pool
* fix(storage): report heterogeneous erasure layouts
* fix(admin): publish prepared storage config atomically
* fix(storage): harden per-pool parity boundaries
* fix(storage): address pre-PR validation findings
* test(ci): fix strict-topology validation fixtures
* fix(heal): preserve delete markers during repair
* refactor(filemeta): drop unused ValidatedFileInfo witness
ValidatedFileInfo wrapped an unread `_file_info` reference alongside an `Option<ValidatedErasureLayout>`, but only the layout was ever consumed. Return the layout directly from `FileInfo::validate` so the sole production consumer (`LocalDisk::check_parts`) and the two unit tests read it without the extra witness type and lifetime.
No behavior change.
* fix(filemeta): keep compressed and MinIO-migrated tiered objects readable
The new decode-path validation rejected several legitimate on-disk shapes that older RustFS and MinIO-migrated data carry, turning readable objects into FileCorrupt:
- Compressed objects written with an unknown upload size persist a negative per-part actual_size (the documented "unknown size" sentinel that ObjectInfo::get_actual_size already tolerates). validate_collection_contents rejected it via usize::try_from; now a negative actual_size skips shard validation and only real, non-negative sizes are checked.
- MinIO-migrated objects transitioned to a versioned remote tier store the tier version id as a UUID string, not 16 raw bytes. MetaObject::into_fileinfo returned FileCorrupt (main tolerated it as None), making all versions of the object unreadable; MetaDeleteMarker free-version records took a Some(nil) sentinel path with the same effect, which also breaks free-version expiry (remote-tier leak). Both now decode through a shared transitioned_version_id_from_meta_sys helper: 16 raw bytes or a UUID string are accepted, anything else is tolerated as None instead of failing the read.
Regression tests updated to assert the readable/compat behavior, with new tests covering MinIO string-form recovery.
* fix(scanner): build the delete-marker test fixture without erasure geometry
get_size_counts_delete_markers_separately_from_versions built its delete marker with `FileInfo::new(object, 1, 1)`, which attaches erasure geometry (data=1/parity=1/distribution). This PR classifies versions by shape via `is_storage_delete_marker()` (no geometry) rather than the raw `deleted` flag, so a geometry-bearing "delete marker" is correctly serialized as a purge-pending payload Object and counted as a version — CI saw summary.versions=3, expected 2.
Real delete markers carry no erasure geometry (delete paths build them as `FileInfo { deleted: true, ..Default::default() }`), so construct the fixture the same way. It then classifies as a storage delete marker and the counts (versions=2, delete_markers=1) hold. This keeps the PR's more-correct classification, which prevents a purge-pending object's geometry from being dropped when serialized as a bare delete marker.
* docs(changelog): note per-pool parity fix and storage-class startup upgrade caveat
Records the #4801 per-pool erasure parity fix under Fixed, and documents the upgrade behavior where a persisted storage class that a small or heterogeneous pool cannot satisfy now fails startup — with the RUSTFS_STORAGE_CLASS_STANDARD recovery steps. Docs-only; covers R4 from the on-disk compatibility audit.
* fix(heal): report parity from erasure geometry, not is_valid()
heal_object set HealResultItem.parity_blocks via `if lfi.is_valid()`, which was missed by the migration of the other quorum/metadata predicates. With the new `is_valid()` semantics (full payload validation; delete markers now return false), a delete marker or a geometry-bearing version with a benign collection quirk would misreport parity as the pool default instead of its own. Use `has_valid_erasure_geometry()` — the narrow "does this carry erasure geometry" predicate the rest of the migration uses — so reporting matches the object's actual layout. Reporting-only; no data-path change.
* fix(filemeta): do not silently serialize a non-canonical deleted FileInfo as an Object
`From<FileInfo> for FileMetaVersion` classifies by `is_storage_delete_marker()` (shape), which correctly routes canonical delete markers to Delete and purge-pending payloads (deleted=true with real erasure geometry) to Object. But a `deleted` FileInfo that is neither a canonical marker nor a valid erasure payload would silently serialize as a zero-geometry MetaObject that later fails `validate_for_metadata_read`. Write paths validate first (`validate_for_erasure_write` / `validate_for_metadata_read`), so this is a caller bug; `From` is infallible, so surface it with a structured `warn!` on the malformed branch instead of writing corrupt metadata silently. Legitimate purge-pending objects (valid geometry) are unaffected — the guard only fires for `deleted && !has_valid_erasure_geometry()`.
* test(filemeta): assert real historical xl.meta versions pass metadata-read validation
Empirical companion to the code-reasoned decode-tolerance invariants (docs/architecture/erasure-coding.md §11) and the rolling-upgrade / MinIO-migration compatibility concern: the tightened `validate_for_metadata_read` runs on every local disk read and peer-RPC-decoded FileInfo, so it must accept every version of real historically-written xl.meta, never reject it as FileCorrupt.
Loads five real fixtures — MinIO small-inline, MinIO versioned (two object versions + a delete marker), MinIO large multipart, a legacy V1 (xl.json-derived) object, and a legacy meta_ver 2 object — decodes every version with parts materialized, and asserts validate_for_metadata_read() is Ok for each. Reverting the tolerant handling (delete-marker shape, legacy per-part checksums, string/short transitioned-versionID, negative actual_size) turns this red.
* fix(ci): remove duplicate storage test re-exports
---------
Co-authored-by: overtrue <anzhengchao@gmail.com>
This commit is contained in:
@@ -395,7 +395,24 @@ impl FileMeta {
|
||||
// delete_version deletes version, returns data_dir
|
||||
#[tracing::instrument(level = "debug", skip(self))]
|
||||
pub fn delete_version(&mut self, fi: &FileInfo) -> Result<Option<Uuid>> {
|
||||
fi.validate_for_delete_operation()?;
|
||||
|
||||
let vid = Some(fi.version_id.unwrap_or(Uuid::nil()));
|
||||
if fi.deleted && fi.tier_free_version() {
|
||||
let Some(index) = self.versions.iter().position(|version| {
|
||||
version.header.version_id == vid
|
||||
&& version.header.version_type == VersionType::Delete
|
||||
&& version.header.free_version()
|
||||
&& version
|
||||
.parse_version_meta()
|
||||
.is_ok_and(|decoded| decoded.free_version() && decoded.header().version_id == vid)
|
||||
}) else {
|
||||
return Err(Error::FileVersionNotFound);
|
||||
};
|
||||
self.versions.remove(index);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let target_is_delete_marker = self
|
||||
.versions
|
||||
.iter()
|
||||
@@ -410,10 +427,6 @@ impl FileMeta {
|
||||
mod_time: fi.mod_time,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if !fi.is_valid() {
|
||||
return Err(Error::other("invalid file meta version"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut update_version = false;
|
||||
@@ -978,6 +991,52 @@ mod test {
|
||||
assert!(!fi.parts.is_empty(), "multipart/part layout present");
|
||||
}
|
||||
|
||||
/// Compatibility guard for the tightened `validate_for_metadata_read()` decode
|
||||
/// boundary (the rolling-upgrade / MinIO-migration risk): every version of
|
||||
/// every real, historically-written `xl.meta` fixture must still be *accepted*
|
||||
/// on read, never rejected as `FileCorrupt`. This is the empirical companion to
|
||||
/// the code-reasoned decode-tolerance invariants in
|
||||
/// `docs/architecture/erasure-coding.md` §11 — reverting the tolerant handling
|
||||
/// (delete-marker shape, legacy per-part checksums, string/short
|
||||
/// `transitioned-versionID`, negative `actual_size`) turns one of these red.
|
||||
/// It covers real MinIO-written objects (inline, versioned incl. a delete
|
||||
/// marker, and multipart), a legacy V1 (`xl.json`-derived) object, and a legacy
|
||||
/// meta_ver 2 object.
|
||||
#[test]
|
||||
fn real_historical_xlmeta_versions_pass_metadata_read_validation() {
|
||||
let fixtures: [(&str, Vec<u8>); 5] = [
|
||||
("minio-small-inline", create_minio_small_object_xlmeta().expect("small fixture")),
|
||||
(
|
||||
"minio-versioned+delete-marker",
|
||||
create_minio_versioned_object_xlmeta().expect("versioned fixture"),
|
||||
),
|
||||
("minio-large-multipart", create_minio_large_object_xlmeta().expect("large fixture")),
|
||||
("legacy-v1-object", create_legacy_v1_object_xlmeta().expect("legacy v1 fixture")),
|
||||
(
|
||||
"legacy-meta-v2-object",
|
||||
create_issue_2265_legacy_meta_v2_object_xlmeta().expect("legacy meta v2 fixture"),
|
||||
),
|
||||
];
|
||||
|
||||
for (label, bytes) in fixtures {
|
||||
let fm = FileMeta::load(&bytes).unwrap_or_else(|e| panic!("{label}: load real xl.meta failed: {e}"));
|
||||
// `all_parts = true` materializes the part arrays so the checksum/part and
|
||||
// shard-size checks in `validate_for_metadata_read` are actually exercised.
|
||||
let versions = fm
|
||||
.list_versions("interop", "object", true)
|
||||
.unwrap_or_else(|e| panic!("{label}: list_versions failed: {e}"));
|
||||
assert!(!versions.is_empty(), "{label}: fixture must contain at least one version");
|
||||
for (i, fi) in versions.iter().enumerate() {
|
||||
fi.validate_for_metadata_read().unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"{label}: version {i} (deleted={}) rejected by validate_for_metadata_read: {e:?}",
|
||||
fi.deleted
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a raw meta block in a valid XL2 container (header, bin32 length
|
||||
/// prefix, and CRC trailer) so decode tests exercise the meta parsing
|
||||
/// itself rather than the envelope checks.
|
||||
@@ -2072,6 +2131,152 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_version_accepts_delete_only_marker_and_free_version_paths() {
|
||||
let marker_version_id = Uuid::new_v4();
|
||||
let mut marker_meta = FileMeta::new();
|
||||
marker_meta
|
||||
.delete_version(&FileInfo {
|
||||
version_id: Some(marker_version_id),
|
||||
deleted: true,
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("delete-marker metadata must not require a payload erasure layout");
|
||||
assert_eq!(marker_meta.versions.len(), 1);
|
||||
assert_eq!(marker_meta.versions[0].header.version_type, VersionType::Delete);
|
||||
|
||||
let object_version_id = Uuid::new_v4();
|
||||
let remote_version_id = Uuid::new_v4();
|
||||
let free_version_id = Uuid::new_v4();
|
||||
let mut free_meta = FileMeta::new();
|
||||
free_meta
|
||||
.add_version(FileInfo {
|
||||
volume: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(object_version_id),
|
||||
transition_status: TRANSITION_COMPLETE.to_string(),
|
||||
transitioned_objname: "remote/object".to_string(),
|
||||
transition_version_id: Some(remote_version_id),
|
||||
transition_tier: "WARM".to_string(),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("transitioned object version must be seeded");
|
||||
|
||||
let mut transition_delete = FileInfo {
|
||||
volume: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(object_version_id),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
transition_delete.set_tier_free_version_id(&free_version_id.to_string());
|
||||
free_meta
|
||||
.delete_version(&transition_delete)
|
||||
.expect("transitioned delete must persist free-version cleanup metadata");
|
||||
|
||||
let mut free_version_delete = FileInfo {
|
||||
volume: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(free_version_id),
|
||||
deleted: true,
|
||||
..Default::default()
|
||||
};
|
||||
free_version_delete.set_tier_free_version();
|
||||
free_meta
|
||||
.delete_version(&free_version_delete)
|
||||
.expect("free-version cleanup must not require a payload erasure layout");
|
||||
|
||||
let versions = free_meta
|
||||
.get_file_info_versions("bucket", "object", false)
|
||||
.expect("versions must remain readable after free-version cleanup");
|
||||
assert!(versions.free_versions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_version_validates_requests_before_mutation() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let mut fm = FileMeta::new();
|
||||
fm.add_version(FileInfo {
|
||||
version_id: Some(version_id),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("object version must be seeded");
|
||||
let original = fm.clone();
|
||||
|
||||
let err = fm
|
||||
.delete_version(&FileInfo {
|
||||
version_id: Some(version_id),
|
||||
parts: vec![
|
||||
ObjectPartInfo {
|
||||
number: 1,
|
||||
..Default::default()
|
||||
},
|
||||
ObjectPartInfo {
|
||||
number: 1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
.expect_err("non-deleted requests must not bypass boundary validation");
|
||||
|
||||
assert_eq!(err, Error::FileCorrupt);
|
||||
assert_eq!(fm, original, "failed validation must happen before metadata mutation");
|
||||
|
||||
let err = fm
|
||||
.delete_version(&FileInfo {
|
||||
version_id: Some(version_id),
|
||||
deleted: true,
|
||||
mod_time: None,
|
||||
..Default::default()
|
||||
})
|
||||
.expect_err("malformed delete markers must fail before removing the existing object version");
|
||||
|
||||
assert_eq!(err, Error::FileCorrupt);
|
||||
assert_eq!(fm, original, "malformed delete-marker validation must precede metadata mutation");
|
||||
|
||||
let mut free_version_delete = FileInfo {
|
||||
version_id: Some(version_id),
|
||||
deleted: true,
|
||||
..Default::default()
|
||||
};
|
||||
free_version_delete.set_tier_free_version();
|
||||
let err = fm
|
||||
.delete_version(&free_version_delete)
|
||||
.expect_err("tier free-version cleanup must not remove an ordinary object version");
|
||||
|
||||
assert_eq!(err, Error::FileVersionNotFound);
|
||||
assert_eq!(fm, original, "missing free-version cleanup must not mutate ordinary metadata");
|
||||
|
||||
let mut poisoned_object_header = fm.clone();
|
||||
poisoned_object_header.versions[0].header.flags |= XL_FLAG_FREE_VERSION;
|
||||
let poisoned_original = poisoned_object_header.clone();
|
||||
let err = poisoned_object_header
|
||||
.delete_version(&free_version_delete)
|
||||
.expect_err("a free-version flag on an Object header must not authorize cleanup deletion");
|
||||
assert_eq!(err, Error::FileVersionNotFound);
|
||||
assert_eq!(
|
||||
poisoned_object_header, poisoned_original,
|
||||
"a poisoned Object header must remain unchanged after rejected cleanup"
|
||||
);
|
||||
|
||||
let mut mismatched_delete_header = fm.clone();
|
||||
mismatched_delete_header.versions[0].header.flags |= XL_FLAG_FREE_VERSION;
|
||||
mismatched_delete_header.versions[0].header.version_type = VersionType::Delete;
|
||||
let mismatched_original = mismatched_delete_header.clone();
|
||||
let err = mismatched_delete_header
|
||||
.delete_version(&free_version_delete)
|
||||
.expect_err("a Delete/free header over an Object body must fail closed");
|
||||
assert_eq!(err, Error::FileVersionNotFound);
|
||||
assert_eq!(
|
||||
mismatched_delete_header, mismatched_original,
|
||||
"header/body mismatch must not mutate metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_file_info_versions_excludes_free_versions_from_num_versions() {
|
||||
let object_version_id = Uuid::new_v4();
|
||||
|
||||
Reference in New Issue
Block a user