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:
cxymds
2026-07-19 21:52:31 +08:00
committed by GitHub
parent 21049401fa
commit b0c6c4cbce
30 changed files with 4613 additions and 427 deletions
+29 -24
View File
@@ -317,11 +317,12 @@ mod tests {
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
use crate::error::StorageError;
use crate::object_api::{ObjectOptions, PutObjReader};
use crate::runtime::instance::InstanceContext;
use crate::storage_api_contracts::{
bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp},
object::{ObjectIO as _, ObjectOperations as _},
};
use crate::store::{ECStore, init_local_disks};
use crate::store::{ECStore, init_local_disks_with_instance_ctx};
use crate::{
disk::endpoint::Endpoint,
layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints},
@@ -373,18 +374,31 @@ mod tests {
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
}]);
init_local_disks(endpoint_pools.clone())
let instance_ctx = Arc::new(InstanceContext::new());
init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
.await
.expect("local disks should initialize");
let ecstore =
ECStore::new("127.0.0.1:0".parse().expect("test address"), endpoint_pools, CancellationToken::new())
.await
.expect("ECStore should initialize");
if metadata_sys::get_global_bucket_metadata_sys().is_none() {
metadata_sys::init_bucket_metadata_sys(ecstore.clone(), Vec::new()).await;
let ecstore = ECStore::new_with_instance_ctx(
"127.0.0.1:0".parse().expect("test address"),
endpoint_pools,
CancellationToken::new(),
instance_ctx,
)
.await
.expect("ECStore should initialize");
let storage_class = crate::config::storageclass::lookup_config_for_pools_without_env(
&rustfs_config::server_config::KVS::new(),
&[4],
)
.expect("bucket test storage class should match its four-disk pool");
for pool in &ecstore.pools {
for set in &pool.disk_set {
set.set_test_storage_class_config(storage_class.clone());
}
}
metadata_sys::init_bucket_metadata_sys(ecstore.clone(), Vec::new()).await;
(disk_paths, ecstore)
})
.await
@@ -490,17 +504,8 @@ mod tests {
);
}
// #[serial] with the crate-wide default key: these tests drive make_bucket /
// delete_bucket through the process-global local-disk registry and lock
// client (see crates/ecstore/src/runtime/global.rs), and through Sets::new
// which reads the process-global erasure mode. Running them concurrently with
// other tests that touch those globals races make_bucket into
// InsufficientWriteQuorum under `cargo test` (single process). serial_test
// serializes them across the
// in-process suite; nextest's per-test processes are covered separately by the
// ecstore-serial-flaky test-group in .config/nextest.toml (backlog #937).
// Full instance-level isolation is blocked on the InstanceContext migration
// (backlog #939) and is not attempted here.
// These tests share one isolated instance and mutate its bucket metadata;
// serialize them so their assertions cannot observe each other's operations.
#[tokio::test]
#[serial]
async fn bucket_delete_mark_delete_marks_metadata_deleted_without_physical_object_delete() {
@@ -509,7 +514,7 @@ mod tests {
let object = "object.txt";
create_bucket_with_object(&ecstore, &bucket, object).await;
assert!(metadata_sys::get(&bucket).await.is_ok());
assert!(metadata_sys::get_in(&ecstore.ctx, &bucket).await.is_ok());
let generation_before_delete = ecstore.scanner_namespace_mutation_generation();
ecstore
@@ -537,7 +542,7 @@ mod tests {
"MarkDelete should persist the deleted-bucket marker"
);
assert!(
metadata_sys::get(&bucket).await.is_err(),
metadata_sys::get_in(&ecstore.ctx, &bucket).await.is_err(),
"deleted bucket metadata must be removed from the local cache"
);
}
@@ -578,7 +583,7 @@ mod tests {
"Purge should remove bucket metadata prefix"
);
assert!(
metadata_sys::get(&bucket).await.is_err(),
metadata_sys::get_in(&ecstore.ctx, &bucket).await.is_err(),
"purged bucket metadata must be removed from the local cache"
);
}
@@ -609,7 +614,7 @@ mod tests {
"failed default S3 DeleteBucket must keep object data"
);
assert!(
metadata_sys::get(&bucket).await.is_ok(),
metadata_sys::get_in(&ecstore.ctx, &bucket).await.is_ok(),
"failed default S3 DeleteBucket must keep metadata cache"
);
}
+24 -1
View File
@@ -212,7 +212,8 @@ impl ECStore {
// Validate topology and environment overrides before opening any disk.
// The values stored on SetDisks remain pure per-pool topology defaults;
// the runtime storage-class snapshot is published later from config.
// payload writes use the runtime storage-class snapshot published later
// from config before the store is marked ready.
let default_pool_parities = resolve_startup_pool_defaults(&endpoint_pools)?;
let mut deployment_id = None;
@@ -857,6 +858,28 @@ mod tests {
assert!(err.to_string().contains("pool 1") && err.to_string().contains("2 drives"));
}
#[test]
#[serial_test::serial(storage_class_env)]
fn startup_pool_defaults_validate_environment_without_changing_metadata_fallback() {
temp_env::with_vars(
[
(crate::config::storageclass::STANDARD_ENV, Some("EC:1")),
(crate::config::storageclass::RRS_ENV, None),
(crate::config::storageclass::OPTIMIZE_ENV, None),
(crate::config::storageclass::INLINE_BLOCK_ENV, None),
],
|| {
let runtime = crate::config::storageclass::lookup_config_for_pools(&KVS::new(), &[6, 4])
.expect("explicit standard parity must resolve the runtime candidate");
assert_eq!(runtime.parities_for_sc(crate::config::storageclass::STANDARD), Some(vec![1, 1]));
let defaults = super::resolve_startup_pool_defaults(&endpoint_pools_with_drive_counts(&[6, 4]))
.expect("explicit standard parity must validate for every pool");
assert_eq!(defaults, vec![3, 2]);
},
);
}
async fn without_storage_class_env<F: Future>(future: F) -> F::Output {
temp_env::async_with_vars(
[
+302 -30
View File
@@ -13,6 +13,7 @@
// limitations under the License.
use super::*;
use crate::config::storageclass;
use crate::layout::pool_space::{ServerPoolsAvailableSpace, build_server_pools_available_space};
use crate::runtime::sources as runtime_sources;
use crate::storage_api_contracts::{admin::StorageAdminApi, namespace::NamespaceLocking as _, object::ObjectOperations as _};
@@ -23,6 +24,142 @@ use support::{
resolve_rebalance_delete_from_all_pools_results, resolve_store_rebalance_pool_meta_reload_result,
};
#[derive(Debug, Default, Eq, PartialEq)]
struct BackendStorageClassInfo {
standard_sc_data: Vec<usize>,
standard_sc_parities: Vec<usize>,
standard_sc_parity: Option<usize>,
rr_sc_data: Vec<usize>,
rr_sc_parities: Vec<usize>,
rr_sc_parity: Option<usize>,
}
fn resolve_pool_layout(
drives_per_set: &[usize],
mut parity_for_pool: impl FnMut(usize, usize) -> Option<usize>,
) -> Option<(Vec<usize>, Vec<usize>)> {
let mut parities = Vec::with_capacity(drives_per_set.len());
let mut data = Vec::with_capacity(drives_per_set.len());
for (pool_index, &drives) in drives_per_set.iter().enumerate() {
if drives == 0 {
return None;
}
let parity = parity_for_pool(pool_index, drives)?;
let data_drives = drives.checked_sub(parity)?;
if data_drives == 0 || parity > data_drives {
return None;
}
data.push(data_drives);
parities.push(parity);
}
Some((parities, data))
}
fn homogeneous_parity(parities: &[usize]) -> Option<usize> {
let first = *parities.first()?;
parities.iter().all(|&parity| parity == first).then_some(first)
}
fn resolve_complete_pool_layouts(
drives_per_set: &[usize],
standard_parity_for_pool: impl FnMut(usize, usize) -> Option<usize>,
rr_parity_for_pool: impl FnMut(usize, usize) -> Option<usize>,
) -> Option<BackendStorageClassInfo> {
let (standard_sc_parities, standard_sc_data) = resolve_pool_layout(drives_per_set, standard_parity_for_pool)?;
let (rr_sc_parities, rr_sc_data) = resolve_pool_layout(drives_per_set, rr_parity_for_pool)?;
for ((&standard_parity, &rr_parity), &drives) in standard_sc_parities.iter().zip(&rr_sc_parities).zip(drives_per_set) {
storageclass::validate_parity_inner(standard_parity, rr_parity, drives).ok()?;
}
Some(BackendStorageClassInfo {
standard_sc_parity: homogeneous_parity(&standard_sc_parities),
standard_sc_data,
standard_sc_parities,
rr_sc_parity: homogeneous_parity(&rr_sc_parities),
rr_sc_data,
rr_sc_parities,
})
}
fn resolve_backend_storage_class_info(
config: &storageclass::Config,
drives_per_set: &[usize],
default_standard_parities: &[usize],
) -> BackendStorageClassInfo {
if drives_per_set.len() != default_standard_parities.len() {
return BackendStorageClassInfo::default();
}
if !config.is_initialized() {
let Some((standard_sc_parities, standard_sc_data)) =
resolve_pool_layout(drives_per_set, |pool_index, _| default_standard_parities.get(pool_index).copied())
else {
return BackendStorageClassInfo::default();
};
return BackendStorageClassInfo {
standard_sc_parity: homogeneous_parity(&standard_sc_parities),
standard_sc_data,
standard_sc_parities,
..Default::default()
};
}
match (config.parities_for_sc(storageclass::STANDARD), config.parities_for_sc(storageclass::RRS)) {
(Some(standard), Some(rr)) if standard.len() == drives_per_set.len() && rr.len() == drives_per_set.len() => {
resolve_complete_pool_layouts(
drives_per_set,
|pool_index, drives| config.parity_for_pool(storageclass::STANDARD, pool_index, drives),
|pool_index, drives| config.parity_for_pool(storageclass::RRS, pool_index, drives),
)
.unwrap_or_default()
}
(None, None) => {
let Some(standard) = config.get_parity_for_sc(storageclass::STANDARD) else {
return BackendStorageClassInfo::default();
};
let Some(rr) = config.get_parity_for_sc(storageclass::RRS) else {
return BackendStorageClassInfo::default();
};
resolve_complete_pool_layouts(drives_per_set, |_, _| Some(standard), |_, _| Some(rr)).unwrap_or_default()
}
_ => BackendStorageClassInfo::default(),
}
}
fn build_backend_info(
config: &storageclass::Config,
drives_per_set: &[usize],
default_standard_parities: &[usize],
total_sets: &[usize],
) -> rustfs_madmin::BackendInfo {
let storage_class_info = if total_sets.len() == drives_per_set.len() {
resolve_backend_storage_class_info(config, drives_per_set, default_standard_parities)
} else {
BackendStorageClassInfo::default()
};
rustfs_madmin::BackendInfo {
backend_type: rustfs_madmin::BackendByte::Erasure,
online_disks: rustfs_madmin::BackendDisks::new(),
offline_disks: rustfs_madmin::BackendDisks::new(),
standard_sc_data: storage_class_info.standard_sc_data,
standard_sc_parities: storage_class_info.standard_sc_parities,
standard_sc_parity: storage_class_info.standard_sc_parity,
rr_sc_data: storage_class_info.rr_sc_data,
rr_sc_parities: storage_class_info.rr_sc_parities,
rr_sc_parity: storage_class_info.rr_sc_parity,
total_sets: total_sets.to_vec(),
drives_per_set: drives_per_set.to_vec(),
}
}
impl ECStore {
#[instrument(level = "debug", skip(self))]
pub(super) async fn delete_all(&self, bucket: &str, prefix: &str) -> Result<()> {
@@ -509,37 +646,12 @@ impl ECStore {
#[instrument(skip(self))]
pub(super) async fn handle_backend_info(&self) -> rustfs_madmin::BackendInfo {
let (standard_sc_parity, rr_sc_parity) =
runtime_sources::backend_storage_class_parities(self.pools[0].default_parity_count);
let drives_per_set = StorageAdminApi::set_drive_counts(self);
let default_standard_parities = self.pools.iter().map(|pool| pool.default_parity_count).collect::<Vec<_>>();
let storage_class = runtime_sources::storage_class_config_snapshot();
let total_sets = self.pools.iter().map(|pool| pool.set_count).collect::<Vec<_>>();
let mut standard_sc_data = Vec::new();
let mut rr_sc_data = Vec::new();
let mut drives_per_set = Vec::new();
let mut total_sets = Vec::new();
for (idx, set_count) in StorageAdminApi::set_drive_counts(self).iter().enumerate() {
if let Some(sc_parity) = standard_sc_parity {
standard_sc_data.push(set_count - sc_parity);
}
if let Some(sc_parity) = rr_sc_parity {
rr_sc_data.push(set_count - sc_parity);
}
total_sets.push(self.pools[idx].set_count);
drives_per_set.push(*set_count);
}
rustfs_madmin::BackendInfo {
backend_type: rustfs_madmin::BackendByte::Erasure,
online_disks: rustfs_madmin::BackendDisks::new(),
offline_disks: rustfs_madmin::BackendDisks::new(),
standard_sc_data,
standard_sc_parity,
rr_sc_data,
rr_sc_parity,
total_sets,
drives_per_set,
..Default::default()
}
build_backend_info(&storage_class, &drives_per_set, &default_standard_parities, &total_sets)
}
#[instrument(skip(self))]
@@ -635,6 +747,166 @@ impl ECStore {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::storageclass::{CLASS_RRS, CLASS_STANDARD, lookup_config_for_pools_without_env};
use arc_swap::ArcSwap;
use rustfs_config::server_config::KVS;
use std::sync::Arc;
fn assert_backend_layout_empty(info: &rustfs_madmin::BackendInfo) {
assert!(info.standard_sc_parities.is_empty());
assert!(info.standard_sc_data.is_empty());
assert_eq!(info.standard_sc_parity, None);
assert!(info.rr_sc_parities.is_empty());
assert!(info.rr_sc_data.is_empty());
assert_eq!(info.rr_sc_parity, None);
}
#[test]
fn build_backend_info_reports_heterogeneous_automatic_config_in_pool_order() {
let config = lookup_config_for_pools_without_env(&KVS::new(), &[4, 2]).expect("automatic storage class should resolve");
let info = build_backend_info(&config, &[4, 2], &[2, 1], &[7, 3]);
assert!(matches!(info.backend_type, rustfs_madmin::BackendByte::Erasure));
assert_eq!(info.standard_sc_parities, vec![2, 1]);
assert_eq!(info.standard_sc_data, vec![2, 1]);
assert_eq!(info.standard_sc_parity, None);
assert_eq!(info.rr_sc_parities, vec![1, 1]);
assert_eq!(info.rr_sc_data, vec![3, 1]);
assert_eq!(info.rr_sc_parity, Some(1));
assert_eq!(info.drives_per_set, vec![4, 2]);
assert_eq!(info.total_sets, vec![7, 3]);
}
#[test]
fn build_backend_info_keeps_truthful_homogeneous_scalar() {
let mut kvs = KVS::new();
kvs.insert(CLASS_STANDARD.to_string(), "EC:2".to_string());
let config =
lookup_config_for_pools_without_env(&kvs, &[4, 6]).expect("explicit storage class should resolve for every pool");
let info = build_backend_info(&config, &[4, 6], &[2, 3], &[1, 1]);
assert_eq!(info.standard_sc_parities, vec![2, 2]);
assert_eq!(info.standard_sc_data, vec![2, 4]);
assert_eq!(info.standard_sc_parity, Some(2));
assert_eq!(info.rr_sc_parities, vec![1, 1]);
assert_eq!(info.rr_sc_data, vec![3, 5]);
assert_eq!(info.rr_sc_parity, Some(1));
}
#[test]
fn build_backend_info_reports_single_disk_pool_without_inventing_parity() {
let config = lookup_config_for_pools_without_env(&KVS::new(), &[4, 1]).expect("single disk pool should resolve");
let info = build_backend_info(&config, &[4, 1], &[2, 0], &[1, 1]);
assert_eq!(info.standard_sc_parities, vec![2, 0]);
assert_eq!(info.standard_sc_data, vec![2, 1]);
assert_eq!(info.standard_sc_parity, None);
assert_eq!(info.rr_sc_parities, vec![1, 0]);
assert_eq!(info.rr_sc_data, vec![3, 1]);
assert_eq!(info.rr_sc_parity, None);
}
#[test]
fn build_backend_info_uses_pool_defaults_only_when_truly_uninitialized() {
let info = build_backend_info(&Default::default(), &[4, 2], &[1, 0], &[1, 1]);
assert_eq!(info.standard_sc_parities, vec![1, 0]);
assert_eq!(info.standard_sc_data, vec![3, 2]);
assert_eq!(info.standard_sc_parity, None);
assert!(info.rr_sc_parities.is_empty());
assert!(info.rr_sc_data.is_empty());
assert_eq!(info.rr_sc_parity, None);
}
#[test]
fn build_backend_info_fails_closed_on_initialized_snapshot_mismatch() {
let config = lookup_config_for_pools_without_env(&KVS::new(), &[4, 2]).expect("automatic storage class should resolve");
let info = build_backend_info(&config, &[4, 6], &[1, 2], &[1, 1]);
assert_backend_layout_empty(&info);
assert_eq!(info.drives_per_set, vec![4, 6]);
assert_eq!(info.total_sets, vec![1, 1]);
}
#[test]
fn build_backend_info_expands_valid_legacy_scalar_snapshot() {
let mut kvs = KVS::new();
kvs.insert(CLASS_STANDARD.to_string(), "EC:2".to_string());
kvs.insert(CLASS_RRS.to_string(), "EC:1".to_string());
let current =
lookup_config_for_pools_without_env(&kvs, &[4, 6]).expect("distinct legacy scalars should resolve for every pool");
let encoded = serde_json::to_string(&current).expect("config should serialize");
let legacy: storageclass::Config = serde_json::from_str(&encoded).expect("legacy scalar config should deserialize");
let info = build_backend_info(&legacy, &[4, 6], &[2, 3], &[1, 1]);
assert_eq!(info.standard_sc_parities, vec![2, 2]);
assert_eq!(info.standard_sc_data, vec![2, 4]);
assert_eq!(info.standard_sc_parity, Some(2));
assert_eq!(info.rr_sc_parities, vec![1, 1]);
assert_eq!(info.rr_sc_data, vec![3, 5]);
assert_eq!(info.rr_sc_parity, Some(1));
}
#[test]
fn build_backend_info_rejects_legacy_scalar_invalid_for_later_pool() {
let current = lookup_config_for_pools_without_env(&KVS::new(), &[4, 2]).expect("automatic config should resolve");
let encoded = serde_json::to_string(&current).expect("config should serialize");
let legacy: storageclass::Config = serde_json::from_str(&encoded).expect("legacy scalar config should deserialize");
let info = build_backend_info(&legacy, &[4, 2], &[2, 1], &[1, 1]);
assert_backend_layout_empty(&info);
}
#[test]
fn build_backend_info_never_reports_invalid_geometry() {
let config = storageclass::Config::default();
assert_backend_layout_empty(&build_backend_info(&config, &[4, 2], &[5, 1], &[1, 1]));
assert_backend_layout_empty(&build_backend_info(&config, &[0], &[0], &[1]));
assert_backend_layout_empty(&build_backend_info(&config, &[4], &[3], &[1]));
}
#[test]
fn build_backend_info_rejects_mismatched_topology_lengths() {
let config = lookup_config_for_pools_without_env(&KVS::new(), &[4, 2]).expect("automatic storage class should resolve");
assert_backend_layout_empty(&build_backend_info(&config, &[4, 2], &[2], &[1, 1]));
assert_backend_layout_empty(&build_backend_info(&config, &[4, 2], &[2, 1], &[1]));
let empty = build_backend_info(&Default::default(), &[], &[], &[]);
assert_backend_layout_empty(&empty);
assert!(empty.drives_per_set.is_empty());
assert!(empty.total_sets.is_empty());
}
#[test]
fn build_backend_info_uses_one_complete_arc_swap_snapshot() {
let old = lookup_config_for_pools_without_env(&KVS::new(), &[4, 2]).expect("old config should resolve");
let mut new_kvs = KVS::new();
new_kvs.insert(CLASS_STANDARD.to_string(), "EC:1".to_string());
let new = lookup_config_for_pools_without_env(&new_kvs, &[4, 2]).expect("new config should resolve");
let snapshots = ArcSwap::from_pointee(old);
let held_old = snapshots.load_full();
snapshots.store(Arc::new(new));
let old_info = build_backend_info(&held_old, &[4, 2], &[2, 1], &[1, 1]);
let new_info = build_backend_info(&snapshots.load_full(), &[4, 2], &[2, 1], &[1, 1]);
assert_eq!(old_info.standard_sc_parities, vec![2, 1]);
assert_eq!(old_info.standard_sc_data, vec![2, 1]);
assert_eq!(old_info.standard_sc_parity, None);
assert_eq!(old_info.rr_sc_parities, vec![1, 1]);
assert_eq!(new_info.standard_sc_parities, vec![1, 1]);
assert_eq!(new_info.standard_sc_data, vec![3, 1]);
assert_eq!(new_info.standard_sc_parity, Some(1));
assert_eq!(new_info.rr_sc_parities, vec![1, 1]);
}
fn object_info_with_mod_time(unix_ts: i64, delete_marker: bool) -> ObjectInfo {
ObjectInfo {