Files
rustfs/crates/ecstore/src/store/init.rs
T
Zhengchao An 91dec123d9 refactor(ecstore): add per-instance InstanceContext, migrate erasure setup type (#4413)
* refactor(ecstore): add per-instance InstanceContext, migrate erasure setup type

Phase 5 of the global-singleton consolidation (backlog#939): begin moving
runtime identity state out of process globals so multiple ECStore instances
can coexist in one process. Isolation is carried by the object graph
(ECStore -> Sets -> SetDisks holding an Arc<InstanceContext>), not a
task-local, which does not propagate across the many internal tokio::spawn
boundaries in the data/background paths.

This first slice migrates the erasure setup type -- previously three
independent process-global bools -- into a single per-instance
RwLock<SetupType> that derives is_erasure / is_dist_erasure / is_erasure_sd,
removing a triple source of truth that could drift out of sync.

- New runtime::instance module: InstanceContext + process bootstrap context.
- The legacy free-function facade (is_erasure/update_erasure_type/...) keeps
  its signatures and forwards to the current instance's context, falling back
  to the bootstrap context before a store is published.
- ECStore gains a pub(crate) ctx field and setup_is_* accessors; its
  constructors adopt the bootstrap context (never mint a fresh one) so startup
  writes and post-construction reads share one cell -- single-instance
  behavior is byte-for-byte unchanged.

Tests: erasure predicate derivation vs the legacy behavior, object-graph
carrier isolation across two ECStore instances, and bootstrap adoption.

Refs: backlog#939 (Phase 5, Slice 1), backlog#653 (item 8)

* refactor(ecstore): thread InstanceContext down the object graph (Phase 5 Slice 2) (#4415)

* refactor(ecstore): source the namespace lock manager per-instance (#4417)

refactor(ecstore): source the namespace lock manager per-instance (Phase 5 Slice 3)

Phase 5 Slice 3 (backlog#939): give each instance its own lock namespace by
sourcing SetDisks' lock manager from the instance context instead of the
process singleton. This removes the false cross-instance mutual exclusion (and
attendant ABBA risk) that a shared GlobalLockManager would cause once multiple
instances coexist.

- InstanceContext gains a `lock_manager: Arc<GlobalLockManager>`. `new()` mints
  a fresh manager (independent per-instance); `bootstrap_ctx()` aliases the
  process singleton via get_global_lock_manager(), so a single-instance
  deployment keeps exactly one shared namespace.
- SetDisks::new sources `local_lock_manager` from `ctx.lock_manager()` (the ctx
  it already adopts), not `runtime_sources::global_lock_manager()`. Single
  instance: same Arc as before, so behavior is unchanged.
- Remove the now-unused `runtime_sources::global_lock_manager()` wrapper.

Tests: bootstrap lock manager aliases the process singleton; two fresh contexts
own distinct managers; a SetDisks' lock manager is the one from its context and
aliases the global singleton in a single-instance build.

Verification: cargo test -p rustfs-ecstore (10 Phase 5 + set_disk locking
regressions green), cargo clippy -p rustfs-ecstore --all-targets (clean),
make pre-commit (pass).

Refs: backlog#939 (Phase 5, Slice 3). Stacked on #4415 (Slice 2).
2026-07-08 15:01:40 +08:00

763 lines
29 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use crate::core::pools::local_decommission_queue_prefix;
use crate::error::is_err_decommission_running;
use crate::runtime::sources as runtime_sources;
use crate::storage_api_contracts::object::EcstoreObjectIO;
use tracing::{debug, error, info, warn};
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_STORE_INIT: &str = "store_init";
const EVENT_DECOMMISSION_RESUME_RETRY: &str = "decommission_resume_retry";
const EVENT_DECOMMISSION_RESUME_FAILED: &str = "decommission_resume_failed";
const EVENT_STORE_FORMAT_RETRY: &str = "store_format_retry";
const EVENT_ECSTORE_INIT_STATUS: &str = "ecstore_init_status";
fn pool_first_endpoint_is_local(pool: &crate::layout::endpoints::PoolEndpoints) -> bool {
pool.endpoints.as_ref().first().is_some_and(|endpoint| endpoint.is_local)
}
fn should_resume_local_decommission(endpoints: &EndpointServerPools, idx: usize) -> Result<bool> {
let pool = endpoints.as_ref().get(idx).ok_or_else(|| {
Error::other(format!(
"store init failed to resolve decommission resume pool index {idx} from current endpoints"
))
})?;
let endpoint = pool.endpoints.as_ref().first().ok_or_else(|| {
Error::other(format!(
"store init failed to resolve decommission resume pool index {idx}: no endpoints available"
))
})?;
Ok(endpoint.is_local)
}
const LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES: usize = 6;
const LOCAL_DECOMMISSION_INITIAL_RESUME_DELAY: Duration = Duration::from_secs(60 * 3);
const LOCAL_DECOMMISSION_RESUME_RETRY_DELAY: Duration = Duration::from_secs(30);
fn should_retry_local_decommission_resume(err: &Error, attempt: usize) -> bool {
matches!(err, Error::ConfigNotFound) && attempt < LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES
}
fn should_auto_start_rebalance_after_init(decommission_running: bool, rebalance_meta_loaded: bool) -> bool {
rebalance_meta_loaded && !decommission_running
}
fn pool_meta_has_active_decommission(meta: &PoolMeta) -> bool {
meta.pools.iter().any(|pool| {
pool.decommission
.as_ref()
.is_some_and(|info| info.has_decommission_state() && !info.complete && !info.failed && !info.canceled)
})
}
fn should_auto_start_rebalance_after_recovered_meta(pool_meta: &PoolMeta, rebalance_meta_loaded: bool) -> bool {
should_auto_start_rebalance_after_init(pool_meta_has_active_decommission(pool_meta), rebalance_meta_loaded)
}
async fn wait_for_local_decommission_resume_delay(rx: &CancellationToken, delay: Duration) -> bool {
tokio::select! {
_ = rx.cancelled() => false,
_ = tokio::time::sleep(delay) => true,
}
}
fn resolve_store_init_stage_result(result: Result<()>, stage: &str) -> Result<()> {
result.map_err(|err| Error::other(format!("store init failed during {stage}: {err}")))
}
async fn load_pool_meta_for_startup<S>(pool: Arc<S>) -> Result<PoolMeta>
where
S: EcstoreObjectIO,
{
let mut meta = PoolMeta::default();
resolve_store_init_stage_result(meta.load_for_startup(pool).await, "load_pool_meta")?;
Ok(meta)
}
async fn save_validated_pool_meta_for_startup<S>(meta: &PoolMeta, pools: Vec<Arc<S>>) -> Result<()>
where
S: EcstoreObjectIO,
{
resolve_store_init_stage_result(meta.save_for_startup(pools).await, "save_validated_pool_meta")
}
async fn resume_local_decommission_after_init(store: Arc<ECStore>, rx: CancellationToken, pool_indices: Vec<usize>) {
for attempt in 0..=LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES {
if rx.is_cancelled() {
return;
}
let result = if pool_indices.len() > 1 {
store
.spawn_decommission_routines(store.clone(), rx.clone(), pool_indices.clone())
.await
} else {
store.decommission(rx.clone(), pool_indices.clone()).await
};
match result {
Ok(()) => return,
Err(err) if is_err_decommission_running(&err) => {
if let Err(spawn_err) = store
.spawn_decommission_routines(store.clone(), rx.clone(), pool_indices.clone())
.await
{
error!(
event = EVENT_DECOMMISSION_RESUME_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
pool_indices = ?pool_indices,
error = %spawn_err,
reason = "spawn_workers_failed",
"Failed to resume decommission workers"
);
}
return;
}
Err(err) if should_retry_local_decommission_resume(&err, attempt) => {
warn!(
event = EVENT_DECOMMISSION_RESUME_RETRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
pool_indices = ?pool_indices,
retry_count = attempt + 1,
retry_limit = LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES + 1,
error = %err,
"Retrying decommission resume after missing config"
);
tokio::select! {
_ = rx.cancelled() => return,
_ = tokio::time::sleep(LOCAL_DECOMMISSION_RESUME_RETRY_DELAY) => {}
}
}
Err(err) => {
error!(
event = EVENT_DECOMMISSION_RESUME_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
pool_indices = ?pool_indices,
error = %err,
reason = "resume_failed",
"Failed to resume decommission"
);
return;
}
}
}
}
impl ECStore {
#[allow(clippy::new_ret_no_self)]
#[instrument(level = "debug", skip(endpoint_pools))]
pub async fn new(address: SocketAddr, endpoint_pools: EndpointServerPools, ctx: CancellationToken) -> Result<Arc<Self>> {
// let layouts = DisksLayout::from_volumes(endpoints.as_slice())?;
let mut deployment_id = None;
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
let mut pools = Vec::with_capacity(endpoint_pools.as_ref().len());
let mut disk_map = HashMap::with_capacity(endpoint_pools.as_ref().len());
let mut local_disks = Vec::new();
debug!(
event = EVENT_ECSTORE_INIT_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
address = %address,
"Initializing ECStore address"
);
let mut host = address.ip().to_string();
if host.is_empty() {
host = runtime_sources::rustfs_host().await
}
let mut port = address.port().to_string();
if port.is_empty() {
port = runtime_sources::rustfs_port().to_string()
}
debug!(
event = EVENT_ECSTORE_INIT_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
host = %host,
port = %port,
"Initializing ECStore host"
);
init_local_peer(&endpoint_pools, &host, &port).await;
// debug!("endpoint_pools: {:?}", endpoint_pools);
let mut common_parity_drives = 0;
for (i, pool_eps) in endpoint_pools.as_ref().iter().enumerate() {
let pool_first_is_local = pool_first_endpoint_is_local(pool_eps);
if common_parity_drives == 0 {
let parity_drives = ec_drives_no_config(pool_eps.drives_per_set)?;
storageclass::validate_parity(parity_drives, pool_eps.drives_per_set)?;
common_parity_drives = parity_drives;
}
// validate_parity(parity_count, pool_eps.drives_per_set)?;
// Build disks with health monitoring available, but do not start
// periodic monitoring until format loading succeeds. Startup RPC
// failures can still spawn recovery probes for peers that come up
// after this node.
let (disks, errs) = init_format::init_disks(
&pool_eps.endpoints,
&DiskOption {
cleanup: true,
health_check: true,
},
)
.await;
check_disk_fatal_errs(&errs)?;
let fm = {
let mut times = 0;
let mut interval = 1;
loop {
match init_format::connect_load_init_formats(
pool_first_is_local,
&disks,
pool_eps.set_count,
pool_eps.drives_per_set,
deployment_id,
)
.await
{
Ok(fm) => break Ok(fm),
// Wrap the final error if we are giving up
Err(e) if times >= 10 => {
break Err(Error::other(format!("store init failed to load formats after {times} retries: {e}")));
}
// Retrying so just drop the error
Err(_) => {}
}
times += 1;
if interval < 16 {
interval *= 2;
}
debug!(
event = EVENT_STORE_FORMAT_RETRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
retry_count = times,
retry_delay_secs = interval,
"Retrying storage format load"
);
select! {
_ = tokio::signal::ctrl_c() => {
info!(
event = EVENT_STORE_FORMAT_RETRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
reason = "ctrl_c",
"Interrupted storage format retry loop"
);
exit(0);
}
_ = sleep(Duration::from_secs(interval)) => {
}
}
// After waiting for peers, clear transient faulty marks so the next attempt can open RPCs again
// (these `DiskStore` handles are reused; `is_faulty()` would otherwise short-circuit).
for disk in disks.iter().flatten() {
disk.reset_health_for_store_init_retry();
}
}
}?;
// Format loading succeeded, enable health monitoring on all disks
for disk in disks.iter().flatten() {
disk.enable_health_check();
}
if deployment_id.is_none() {
deployment_id = Some(fm.id);
}
if deployment_id != Some(fm.id) {
return Err(Error::other("store init failed: deployment IDs do not match across pools"));
}
if deployment_id.is_some_and(|id| id.is_nil()) {
deployment_id = Some(Uuid::new_v4());
}
for disk in disks.iter() {
if disk.is_some() && disk.as_ref().expect("operation should succeed").is_local() {
local_disks.push(disk.as_ref().expect("operation should succeed").clone());
}
}
let sets = Sets::new(disks.clone(), pool_eps, &fm, i, common_parity_drives).await?;
pools.push(sets);
disk_map.insert(i, disks);
}
// Replace the local disk
if !runtime_sources::setup_is_dist_erasure().await {
runtime_sources::record_local_disks(local_disks).await;
}
let peer_sys = S3PeerSys::new(&endpoint_pools);
let mut pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
pool_meta.dont_save = true;
let decommission_cancelers = RwLock::new(vec![None; pools.len()]);
let ec = Arc::new(ECStore {
id: deployment_id.ok_or_else(|| Error::other("store init failed: deployment id is not initialized"))?,
disk_map,
pools,
peer_sys,
pool_meta: RwLock::new(pool_meta),
rebalance_meta: RwLock::new(None),
decommission_cancelers,
start_gate: Mutex::new(()),
pool_meta_save_gate: Mutex::new(()),
// Adopt the process bootstrap context so startup writes (erasure
// type recorded before this point) and later reads share one cell.
ctx: crate::runtime::instance::bootstrap_ctx(),
});
// Only set it when the global deployment ID is not yet configured
if let Some(dep_id) = deployment_id {
runtime_sources::ensure_deployment_id(dep_id);
}
let wait_sec = 5;
let mut exit_count = 0;
loop {
if let Err(err) = ec.init(ctx.clone()).await {
error!("init err: {}", err);
error!("retry after {} second", wait_sec);
sleep(Duration::from_secs(wait_sec)).await;
if exit_count > 10 {
return Err(Error::other("store init failed: init retry budget exhausted"));
}
exit_count += 1;
continue;
}
break;
}
runtime_sources::publish_object_store(ec.clone()).await;
Ok(ec)
}
#[instrument(level = "debug", skip(self, rx))]
pub async fn init(self: &Arc<Self>, rx: CancellationToken) -> Result<()> {
runtime_sources::ensure_boot_time().await;
let meta = load_pool_meta_for_startup(
self.pools
.first()
.cloned()
.ok_or_else(|| Error::other("store init failed: no storage pools available"))?,
)
.await?;
let update = meta.validate(self.pools.clone())?;
let endpoints = runtime_sources::endpoint_pools_or_default();
let should_persist_pool_meta = runtime_sources::first_cluster_node_is_local().await;
let installed_pool_meta = if !update {
meta.clone()
} else {
let new_meta = PoolMeta::new(&self.pools, &meta);
// Only one local node should persist validated pool metadata here; otherwise
// distributed startup can race on the same lock and replay the prior init bug.
if should_persist_pool_meta {
save_validated_pool_meta_for_startup(&new_meta, self.pools.clone()).await?;
}
new_meta
};
{
let mut pool_meta = self.pool_meta.write().await;
*pool_meta = installed_pool_meta.clone();
}
resolve_store_init_stage_result(self.load_rebalance_meta().await, "load_rebalance_meta")?;
let rebalance_meta_loaded = self.rebalance_meta.read().await.is_some();
let decommission_running =
pool_meta_has_active_decommission(&installed_pool_meta) || self.is_decommission_running().await;
if should_auto_start_rebalance_after_init(decommission_running, rebalance_meta_loaded) {
resolve_store_init_stage_result(self.start_rebalance().await, "start_rebalance")?;
} else if decommission_running && rebalance_meta_loaded {
warn!(
event = EVENT_ECSTORE_INIT_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
stage = "start_rebalance",
reason = "active_decommission",
"Deferred rebalance auto-start during store init because decommission is active"
);
}
let pools = installed_pool_meta.return_resumable_pools();
let mut pool_indices = Vec::with_capacity(pools.len());
for p in pools.iter() {
if let Some(idx) = endpoints.get_pool_idx(&p.cmd_line) {
pool_indices.push(idx);
} else {
return Err(Error::other(format!(
"store init failed to resolve resumable decommission pool `{}` from current endpoints",
p.cmd_line
)));
}
}
let local_pool_indices = local_decommission_queue_prefix(&endpoints, &pool_indices)?;
if !local_pool_indices.is_empty() {
let store = self.clone();
tokio::spawn(async move {
if !wait_for_local_decommission_resume_delay(&rx, LOCAL_DECOMMISSION_INITIAL_RESUME_DELAY).await {
return;
}
resume_local_decommission_after_init(store, rx, local_pool_indices).await;
});
}
runtime_sources::init_bucket_monitor_for_current_endpoints();
init_background_expiry(self.clone()).await;
crate::bucket::lifecycle::bucket_lifecycle_ops::init_background_stale_multipart_upload_cleanup(self.clone());
TransitionState::init(self.clone()).await;
crate::services::tier::tier::try_migrate_tiering_config(self.clone()).await;
if let Err(err) = runtime_sources::init_tier_config_mgr(self.clone()).await {
info!("TierConfigMgr init error: {}", err);
}
Ok(())
}
pub fn init_local_disks() {}
pub fn single_pool(&self) -> bool {
self.pools.len() == 1
}
}
#[cfg(test)]
mod tests {
use super::{
LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES, load_pool_meta_for_startup, pool_first_endpoint_is_local,
resolve_store_init_stage_result, save_validated_pool_meta_for_startup, should_auto_start_rebalance_after_init,
should_auto_start_rebalance_after_recovered_meta, should_resume_local_decommission,
should_retry_local_decommission_resume, wait_for_local_decommission_resume_delay,
};
use crate::{
core::pools::{POOL_META_VERSION, PoolDecommissionInfo, PoolMeta, PoolStatus},
disk::endpoint::Endpoint,
error::{Error, Result, StorageError},
layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints},
object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader},
services::rebalance::RebalanceMeta,
storage_api_contracts::{object::ObjectIO, range::HTTPRangeSpec},
};
use http::HeaderMap;
use std::{
io::Cursor,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use time::OffsetDateTime;
use tokio_util::sync::CancellationToken;
#[derive(Debug)]
struct StartupPoolMetaStorage {
read_payload: Vec<u8>,
read_without_lock: AtomicBool,
wrote_without_lock: AtomicBool,
wrote_with_max_parity: AtomicBool,
}
impl StartupPoolMetaStorage {
fn new(read_payload: Vec<u8>) -> Self {
Self {
read_payload,
read_without_lock: AtomicBool::new(false),
wrote_without_lock: AtomicBool::new(false),
wrote_with_max_parity: AtomicBool::new(false),
}
}
fn object_info(&self, bucket: &str, object: &str, size: usize) -> ObjectInfo {
ObjectInfo {
bucket: bucket.to_string(),
name: object.to_string(),
size: size as i64,
actual_size: size as i64,
..Default::default()
}
}
}
#[async_trait::async_trait]
impl ObjectIO for StartupPoolMetaStorage {
type Error = Error;
type RangeSpec = HTTPRangeSpec;
type HeaderMap = HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = GetObjectReader;
type PutObjectReader = PutObjReader;
async fn get_object_reader(
&self,
bucket: &str,
object: &str,
_range: Option<HTTPRangeSpec>,
_h: HeaderMap,
opts: &ObjectOptions,
) -> Result<GetObjectReader> {
assert!(opts.no_lock, "store init pool metadata load must not require namespace locks");
self.read_without_lock.store(true, Ordering::SeqCst);
Ok(GetObjectReader {
stream: Box::new(Cursor::new(self.read_payload.clone())),
object_info: self.object_info(bucket, object, self.read_payload.len()),
buffered_body: None,
})
}
async fn put_object(
&self,
bucket: &str,
object: &str,
_data: &mut PutObjReader,
opts: &ObjectOptions,
) -> Result<ObjectInfo> {
assert!(opts.no_lock, "store init pool metadata save must not require namespace locks");
self.wrote_without_lock.store(true, Ordering::SeqCst);
self.wrote_with_max_parity.store(opts.max_parity, Ordering::SeqCst);
Ok(self.object_info(bucket, object, 0))
}
}
fn init_test_pool_meta(decommission: Option<PoolDecommissionInfo>) -> PoolMeta {
PoolMeta {
version: POOL_META_VERSION,
pools: vec![PoolStatus {
id: 0,
cmd_line: "pool-0".to_string(),
last_update: OffsetDateTime::UNIX_EPOCH,
decommission,
}],
dont_save: false,
}
}
#[tokio::test]
async fn test_store_init_pool_meta_io_bypasses_namespace_lock_surface() {
let storage = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
let loaded = load_pool_meta_for_startup(storage.clone())
.await
.expect("startup pool metadata load should tolerate missing metadata without locks");
assert!(loaded.pools.is_empty());
assert!(storage.read_without_lock.load(Ordering::SeqCst));
let meta = PoolMeta {
version: POOL_META_VERSION,
pools: Vec::new(),
dont_save: false,
};
save_validated_pool_meta_for_startup(&meta, vec![storage.clone()])
.await
.expect("startup pool metadata save should bypass locks");
assert!(storage.wrote_without_lock.load(Ordering::SeqCst));
assert!(storage.wrote_with_max_parity.load(Ordering::SeqCst));
}
#[test]
fn test_should_resume_local_decommission_respects_local_flag() {
let mut local_endpoint = Endpoint::try_from("http://127.0.0.1:9000/data").expect("endpoint should parse");
local_endpoint.is_local = true;
let endpoints = EndpointServerPools::from(vec![PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 1,
endpoints: Endpoints::from(vec![local_endpoint]),
cmd_line: "pool-0".to_string(),
platform: String::new(),
}]);
assert!(should_resume_local_decommission(&endpoints, 0).expect("local endpoint should resume"));
}
#[test]
fn test_should_resume_local_decommission_rejects_unresolvable_pool() {
let endpoints = EndpointServerPools::default();
let err = should_resume_local_decommission(&endpoints, 0).expect_err("missing pool should error");
assert_eq!(
err.to_string(),
"Io error: store init failed to resolve decommission resume pool index 0 from current endpoints"
);
}
#[test]
fn test_should_resume_local_decommission_rejects_missing_endpoint() {
let endpoints = EndpointServerPools::from(vec![PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 1,
endpoints: Endpoints::from(Vec::<Endpoint>::new()),
cmd_line: "pool-0".to_string(),
platform: String::new(),
}]);
let err = should_resume_local_decommission(&endpoints, 0).expect_err("missing endpoint should error");
assert_eq!(
err.to_string(),
"Io error: store init failed to resolve decommission resume pool index 0: no endpoints available"
);
}
#[test]
fn test_should_retry_local_decommission_resume_accepts_config_not_found_before_retry_limit() {
assert!(should_retry_local_decommission_resume(&StorageError::ConfigNotFound, 0));
}
#[test]
fn test_should_retry_local_decommission_resume_rejects_config_not_found_at_retry_limit() {
assert!(!should_retry_local_decommission_resume(
&StorageError::ConfigNotFound,
LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES
));
}
#[test]
fn test_should_retry_local_decommission_resume_rejects_non_config_errors() {
assert!(!should_retry_local_decommission_resume(&StorageError::SlowDown, 0));
}
#[test]
fn test_should_auto_start_rebalance_after_init_allows_loaded_rebalance_without_decommission() {
assert!(should_auto_start_rebalance_after_init(false, true));
}
#[test]
fn test_should_auto_start_rebalance_after_init_rejects_active_decommission() {
assert!(!should_auto_start_rebalance_after_init(true, true));
}
#[test]
fn test_should_auto_start_rebalance_after_init_rejects_missing_rebalance_meta() {
assert!(!should_auto_start_rebalance_after_init(false, false));
}
#[test]
fn test_store_init_recovery_skips_rebalance_when_decommission_metadata_is_active() {
let pool_meta = init_test_pool_meta(Some(PoolDecommissionInfo {
start_time: Some(OffsetDateTime::UNIX_EPOCH),
complete: false,
failed: false,
canceled: false,
..Default::default()
}));
let rebalance_meta = Some(RebalanceMeta::default());
assert!(!should_auto_start_rebalance_after_recovered_meta(&pool_meta, rebalance_meta.is_some()));
}
#[test]
fn test_store_init_recovery_allows_rebalance_when_only_rebalance_metadata_exists() {
let pool_meta = init_test_pool_meta(None);
let rebalance_meta = Some(RebalanceMeta::default());
assert!(should_auto_start_rebalance_after_recovered_meta(&pool_meta, rebalance_meta.is_some()));
}
#[test]
fn test_resolve_store_init_stage_result_passthrough_ok() {
resolve_store_init_stage_result(Ok(()), "load_rebalance_meta").expect("successful stage should pass through");
}
#[test]
fn test_resolve_store_init_stage_result_wraps_error_context() {
let err = resolve_store_init_stage_result(Err(StorageError::SlowDown), "start_rebalance")
.expect_err("failed stage should be wrapped");
let err_message = err.to_string();
assert!(err_message.contains("store init failed during start_rebalance"));
assert!(err_message.contains(&StorageError::SlowDown.to_string()));
}
#[tokio::test]
async fn test_wait_for_local_decommission_resume_delay_returns_true_after_delay() {
let rx = CancellationToken::new();
assert!(wait_for_local_decommission_resume_delay(&rx, Duration::from_millis(1)).await);
}
#[tokio::test]
async fn test_wait_for_local_decommission_resume_delay_returns_false_when_cancelled() {
let rx = CancellationToken::new();
rx.cancel();
assert!(!wait_for_local_decommission_resume_delay(&rx, Duration::from_secs(1)).await);
}
#[test]
fn test_pool_first_endpoint_is_local_uses_pool_scope_for_expansion() {
let mut remote_endpoint = Endpoint::try_from("http://127.0.0.2:9000/data1").expect("remote endpoint should parse");
remote_endpoint.is_local = false;
let mut local_endpoint = Endpoint::try_from("http://127.0.0.1:9000/data1").expect("local endpoint should parse");
local_endpoint.is_local = true;
let endpoints = EndpointServerPools::from(vec![
PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 1,
endpoints: Endpoints::from(vec![remote_endpoint]),
cmd_line: "pool-0".to_string(),
platform: String::new(),
},
PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 1,
endpoints: Endpoints::from(vec![local_endpoint]),
cmd_line: "pool-1".to_string(),
platform: String::new(),
},
]);
assert!(!endpoints.first_local(), "cluster first endpoint is intentionally remote");
assert!(
pool_first_endpoint_is_local(endpoints.as_ref().get(1).expect("second pool should exist")),
"the expanded pool should be initialized by its own first local endpoint"
);
}
}