mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-01 17:58:22 +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:
@@ -245,12 +245,12 @@ impl SetDisks {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !metadata.is_valid() {
|
||||
if !file_info_is_valid_for_metadata(metadata) {
|
||||
parities[index] = -1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if metadata.deleted || metadata.size == 0 {
|
||||
if metadata.is_canonical_delete_marker() || metadata.size == 0 {
|
||||
parities[index] = half;
|
||||
} else if metadata.transition_status == TRANSITION_COMPLETE {
|
||||
let majority_metadata_parity = total_shards_i32 - (half + 1);
|
||||
@@ -327,7 +327,7 @@ impl SetDisks {
|
||||
for (i, etag_item) in etags.iter().enumerate() {
|
||||
if let Some(etag_item) = etag_item
|
||||
&& etag_item == &etag
|
||||
&& parts_metadata[i].is_valid()
|
||||
&& file_info_is_valid_for_metadata(&parts_metadata[i])
|
||||
{
|
||||
new_disk[i].clone_from(&disks[i]);
|
||||
}
|
||||
@@ -340,7 +340,7 @@ impl SetDisks {
|
||||
let mut new_disk = vec![None; disks.len()];
|
||||
|
||||
for (i, &t) in mod_times.iter().enumerate() {
|
||||
if parts_metadata[i].is_valid() && mod_time == t {
|
||||
if file_info_is_valid_for_metadata(&parts_metadata[i]) && mod_time == t {
|
||||
new_disk[i].clone_from(&disks[i]);
|
||||
}
|
||||
}
|
||||
@@ -357,7 +357,7 @@ impl SetDisks {
|
||||
continue;
|
||||
}
|
||||
|
||||
if meta.is_valid() {
|
||||
if file_info_is_valid_for_metadata(meta) {
|
||||
usable_metadata += 1;
|
||||
}
|
||||
}
|
||||
@@ -388,7 +388,7 @@ impl SetDisks {
|
||||
|
||||
let mut identity_counts = HashMap::with_capacity(usable_metadata);
|
||||
for (meta, err) in parts_metadata.iter().zip(errs.iter()) {
|
||||
if err.is_some() || !meta.is_valid() {
|
||||
if err.is_some() || !file_info_is_valid_for_metadata(meta) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -613,7 +613,7 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
if !meta.deleted && meta.size != 0 {
|
||||
if !meta.is_canonical_delete_marker() && meta.size != 0 {
|
||||
hasher.update(meta.erasure.data_blocks.to_le_bytes());
|
||||
hasher.update(meta.erasure.parity_blocks.to_le_bytes());
|
||||
hasher.update(meta.erasure.distribution.len().to_le_bytes());
|
||||
@@ -626,7 +626,7 @@ impl SetDisks {
|
||||
fn latest_fileinfo_identity_groups(parts_metadata: &[FileInfo], errs: &[Option<DiskError>]) -> Vec<FileInfoIdentityGroup> {
|
||||
let mut groups: Vec<FileInfoIdentityGroup> = Vec::with_capacity(parts_metadata.len());
|
||||
for (meta, err) in parts_metadata.iter().zip(errs.iter()) {
|
||||
if err.is_some() || !meta.is_valid() {
|
||||
if err.is_some() || !file_info_is_valid_for_metadata(meta) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -658,7 +658,7 @@ impl SetDisks {
|
||||
let mut count = 0;
|
||||
|
||||
for (i, ((meta, err), disk)) in parts_metadata.iter().zip(errs.iter()).zip(disks.iter()).enumerate() {
|
||||
if err.is_some() || !meta.is_valid() || Self::file_info_quorum_hash(meta) != hash {
|
||||
if err.is_some() || !file_info_is_valid_for_metadata(meta) || Self::file_info_quorum_hash(meta) != hash {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -731,7 +731,7 @@ impl SetDisks {
|
||||
let mut meta_hashes = vec![None; metas.len()];
|
||||
|
||||
for (i, meta) in metas.iter().enumerate() {
|
||||
if !meta.is_valid() {
|
||||
if !file_info_is_valid_for_metadata(meta) {
|
||||
debug!(
|
||||
index = i,
|
||||
valid = false,
|
||||
@@ -807,7 +807,7 @@ impl SetDisks {
|
||||
if let Some(hash) = op_hash
|
||||
&& let Some(max_hash) = max_val
|
||||
&& *hash == max_hash
|
||||
&& metas[i].is_valid()
|
||||
&& file_info_is_valid_for_metadata(&metas[i])
|
||||
{
|
||||
if !found {
|
||||
found_fi = Some(metas[i].clone());
|
||||
@@ -861,7 +861,7 @@ impl SetDisks {
|
||||
|
||||
let mut inconsistent = 0;
|
||||
for (k, v) in parts_metadata.iter().enumerate() {
|
||||
if disks[k].is_none() || !v.is_valid() || distribution[k] != v.erasure.index {
|
||||
if disks[k].is_none() || !v.has_valid_erasure_geometry() || distribution[k] != v.erasure.index {
|
||||
inconsistent += 1;
|
||||
}
|
||||
}
|
||||
@@ -877,9 +877,9 @@ impl SetDisks {
|
||||
continue;
|
||||
}
|
||||
let eligible = if use_by_index {
|
||||
parts_metadata[k].is_valid() && distribution[k] == parts_metadata[k].erasure.index
|
||||
parts_metadata[k].has_valid_erasure_geometry() && distribution[k] == parts_metadata[k].erasure.index
|
||||
} else {
|
||||
init || parts_metadata[k].is_valid()
|
||||
init || parts_metadata[k].has_valid_erasure_geometry()
|
||||
};
|
||||
if !eligible {
|
||||
continue;
|
||||
@@ -917,7 +917,7 @@ impl SetDisks {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !v.is_valid() {
|
||||
if !v.has_valid_erasure_geometry() {
|
||||
inconsistent += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -963,7 +963,7 @@ impl SetDisks {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !init && !parts_metadata[k].is_valid() {
|
||||
if !init && !parts_metadata[k].has_valid_erasure_geometry() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1156,6 +1156,43 @@ mod tests {
|
||||
assert_ne!(SetDisks::file_info_quorum_hash(&left), SetDisks::file_info_quorum_hash(&right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_pending_quorum_hash_keeps_erasure_layouts_separate() {
|
||||
let mod_time = OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
let version_id = Uuid::new_v4();
|
||||
let data_dir = Uuid::new_v4();
|
||||
let mut honest = FileInfo::new("bucket/object", 5, 1);
|
||||
honest.name = "bucket/object".to_string();
|
||||
honest.version_id = Some(version_id);
|
||||
honest.data_dir = Some(data_dir);
|
||||
honest.mod_time = Some(mod_time);
|
||||
honest.size = 1;
|
||||
honest.deleted = true;
|
||||
honest.add_object_part(1, "part-etag".to_string(), 1, Some(mod_time), 1, None, None);
|
||||
|
||||
let mut parts_metadata = (1..=6)
|
||||
.map(|index| {
|
||||
let mut metadata = honest.clone();
|
||||
metadata.erasure.index = index;
|
||||
metadata
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut tampered_layout = FileInfo::new("bucket/object", 3, 3).erasure;
|
||||
tampered_layout.index = 1;
|
||||
parts_metadata[0].erasure = tampered_layout;
|
||||
let errs = vec![None; 6];
|
||||
|
||||
assert_eq!(
|
||||
SetDisks::object_quorum_from_meta(&parts_metadata, &errs, 3)
|
||||
.expect("five honest EC:1 payload copies should determine object quorum"),
|
||||
(5, 5)
|
||||
);
|
||||
let selected = SetDisks::find_file_info_in_quorum(&parts_metadata, &Some(mod_time), &None, 5)
|
||||
.expect("the five matching EC:1 payload copies should determine metadata identity");
|
||||
assert_eq!(selected.erasure.data_blocks, 5);
|
||||
assert_eq!(selected.erasure.parity_blocks, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quorum_helpers_reject_zero_quorum_and_shuffle_check_parts_by_distribution() {
|
||||
let err = SetDisks::find_file_info_in_quorum(&[], &None, &None, 0).expect_err("zero quorum cannot select metadata");
|
||||
|
||||
Reference in New Issue
Block a user