mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-29 08:27:06 +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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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(¤t).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(¤t).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 {
|
||||
|
||||
Reference in New Issue
Block a user