mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-05 19:55:37 +00:00
cbfd5b92f4
* fix(ecstore): drain durable control-plane write tails * fix(ecstore): retain PUT staging after incomplete rollback * fix(ecstore): drain backfill checkpoint before confirmation * refactor(ecstore): isolate local object rename commit * refactor(ecstore): remove moved quota fence import * fix(ecstore): retain per-disk rename rollback outcomes * fix(ecstore): retain indeterminate rename recovery evidence * test(ecstore): mark rollback fixtures as inline data * test(ecstore): match sealed context fixture map type * test(ecstore): match sealed context fixture map type * fix(ecstore): preserve known preflight rename rejections * test(ecstore): cover observed rename outer failures * test(ecstore): count decommission faults across retry restarts
26689 lines
1.0 MiB
Plaintext
26689 lines
1.0 MiB
Plaintext
// 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 crate::bucket::replication::replication_state_from_filemeta;
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
use crate::bucket::utils::is_meta_bucketname;
|
|
use crate::bucket::versioning::VersioningApi as _;
|
|
use crate::bucket::versioning_sys::BucketVersioningSys;
|
|
use crate::bucket::{
|
|
lifecycle::{
|
|
DurableIlmRecordCheckpoint, ILM_META_PREFIX, LifecycleExpiryConfigs, TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE,
|
|
ValidatedDurableIlmRecord,
|
|
bucket_lifecycle_audit::LcEventSrc,
|
|
bucket_lifecycle_ops::{
|
|
LifecycleOps, apply_expiry_on_transitioned_object, apply_expiry_rule_for_data_movement, apply_expiry_rule_in,
|
|
eval_action_from_lifecycle, lifecycle_delete_all_versions_blocked_by_replication,
|
|
},
|
|
classify_durable_ilm_record, get_expiry_configs,
|
|
lifecycle::IlmAction,
|
|
tier_delete_journal::durable_ilm_v6_topology_generation,
|
|
validate_durable_ilm_record,
|
|
},
|
|
metadata_sys,
|
|
};
|
|
use crate::cache_value::metacache_set::{ListPathRawOptions, list_path_raw};
|
|
use crate::config::com::{
|
|
CONFIG_PREFIX, delete_config, read_config_limited_preserve_empty, read_config_limited_preserve_empty_with_metadata,
|
|
read_config_no_lock_preserve_empty_with_metadata, read_config_preserve_empty, save_config_with_opts,
|
|
save_config_with_opts_and_metadata,
|
|
};
|
|
use crate::data_movement;
|
|
use crate::data_movement::backpressure::{self, DataMovementOperation};
|
|
use crate::data_usage::DATA_USAGE_CACHE_NAME;
|
|
use crate::disk::error::DiskError;
|
|
use crate::disk::{BUCKET_META_PREFIX, DiskAPI, RUSTFS_META_BUCKET};
|
|
use crate::error::{Error, Result};
|
|
use crate::error::{
|
|
StorageError, is_err_bucket_exists, is_err_bucket_not_found, is_err_object_not_found, is_err_operation_canceled,
|
|
is_err_version_not_found,
|
|
};
|
|
use crate::layout::endpoints::EndpointServerPools;
|
|
use crate::object_api::{DecommissionCapacityOptions, GetObjectReader, ObjectInfo, ObjectOptions};
|
|
use crate::runtime::sources as runtime_sources;
|
|
use crate::services::notification_sys::{
|
|
acquire_tier_delete_journal_fleet_proof, tier_delete_journal_fleet_proof_matches, tier_delete_journal_topology_generation,
|
|
};
|
|
use crate::services::rebalance::{REBAL_META_NAME, RebalanceMeta, is_rebalance_conflicting_with_decommission};
|
|
use crate::set_disk::{SetDisks, get_lock_acquire_timeout};
|
|
use crate::storage_api_contracts::{
|
|
admin::StorageAdminApi,
|
|
bucket::{BucketOperations, BucketOptions, MakeBucketOptions},
|
|
heal::HealOperations as _,
|
|
list::ListOperations as _,
|
|
namespace::NamespaceLocking as _,
|
|
object::{EcstoreObjectIO, HTTPPreconditions, ObjectIO as _, ObjectOperations as _},
|
|
};
|
|
use crate::{core::sets::Sets, store::ECStore};
|
|
use byteorder::{ByteOrder, LittleEndian, WriteBytesExt};
|
|
use futures::{
|
|
StreamExt,
|
|
future::{BoxFuture, join_all},
|
|
stream::FuturesUnordered,
|
|
};
|
|
use http::HeaderMap;
|
|
#[cfg(test)]
|
|
use rmp_serde::Deserializer;
|
|
use rmp_serde::Serializer;
|
|
use rustfs_filemeta::{FileInfo, FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams};
|
|
use rustfs_heal_contracts::heal_channel::HealOpts;
|
|
use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum};
|
|
use rustfs_utils::path::{
|
|
decode_dir_object, encode_dir_object, path_join, path_to_bucket_object, path_to_bucket_object_with_base_path,
|
|
};
|
|
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ReplicationConfiguration};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fmt::Display;
|
|
#[cfg(test)]
|
|
use std::io::Cursor;
|
|
use std::io::Write;
|
|
use std::path::PathBuf;
|
|
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, AtomicUsize, Ordering},
|
|
};
|
|
use time::{Duration, OffsetDateTime};
|
|
use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
|
|
use tokio_util::sync::CancellationToken;
|
|
use tracing::{debug, error, info, warn};
|
|
|
|
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
|
const LOG_SUBSYSTEM_POOLS: &str = "pools";
|
|
const EVENT_DECOMMISSION_STATE: &str = "decommission_state";
|
|
const EVENT_DECOMMISSION_BUCKET: &str = "decommission_bucket";
|
|
const EVENT_DECOMMISSION_ENTRY: &str = "decommission_entry";
|
|
const POOL_ACTIVATION_FLEET_PROOF_REQUIRED: &str = "pool activation requires a live fleet capability proof";
|
|
const POOL_ACTIVATION_FLEET_PROOF_EXPIRED: &str = "pool activation fleet capability proof expired before commit";
|
|
const DECOMMISSION_TARGET_FLEET_PROOF_EXPIRED: &str =
|
|
"decommission target fence fleet capability proof expired before reservation commit";
|
|
const DECOMMISSION_STAGE_MIGRATE_OBJECT: &str = "migrate_object";
|
|
const DECOMMISSION_STAGE_CLEANUP_PREFLIGHT: &str = "cleanup_preflight";
|
|
const DECOMMISSION_STAGE_SOURCE_CLEANUP: &str = "source_cleanup";
|
|
const DECOMMISSION_STAGE_ENTRY_FINISHED: &str = "entry_finished";
|
|
const DECOMMISSION_PROGRESS_SAVE_INTERVAL: Duration = Duration::seconds(30);
|
|
const DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD: usize = 1000;
|
|
const DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF: Duration = Duration::seconds(1);
|
|
const DECOMMISSION_BUCKET_CONCURRENCY_ENV: &str = "RUSTFS_DECOMMISSION_BUCKET_CONCURRENCY";
|
|
const DECOMMISSION_BUCKET_CONCURRENCY_DEFAULT_CAP: usize = 4;
|
|
const DECOMMISSION_ENTRY_CONCURRENCY_ENV: &str = "RUSTFS_DECOMMISSION_ENTRY_CONCURRENCY";
|
|
const DECOMMISSION_ENTRY_CONCURRENCY_DEFAULT_CAP: usize = 8;
|
|
const DECOMMISSION_ENTRY_CONCURRENCY_HARD_CAP: usize = 64;
|
|
const DECOMMISSION_ENTRY_WORKERS_PER_SET: usize = 2;
|
|
const DECOMMISSION_META_PREFIXES: [&str; 3] = [CONFIG_PREFIX, BUCKET_META_PREFIX, ILM_META_PREFIX];
|
|
const DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION: u16 = 1;
|
|
const DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION: u16 = 2;
|
|
#[cfg(test)]
|
|
const DECOMMISSION_CAPACITY_MODEL_VERSION: u16 = DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION;
|
|
const DECOMMISSION_CAPACITY_TEMPORARY_COPIES: usize = 1;
|
|
const DECOMMISSION_CAPACITY_RESERVATION_TTL: Duration = Duration::minutes(10);
|
|
const DECOMMISSION_CAPACITY_RELEASE_CANCELED: &str = "canceled";
|
|
const DECOMMISSION_CAPACITY_RELEASE_FAILED: &str = "failed";
|
|
const DECOMMISSION_CAPACITY_RELEASE_COMPLETED: &str = "completed";
|
|
pub(crate) const DECOMMISSION_CAPACITY_TARGET_LOCK_PREFIX: &str = "decommission/capacity-target";
|
|
const DECOMMISSION_CAPACITY_TARGET_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250);
|
|
const DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_PREFIX: &str = "target pool ";
|
|
const DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_SUFFIX: &str = " target capacity mutation gate is busy";
|
|
const METRIC_DECOMMISSION_CAPACITY_CONFLICTS_TOTAL: &str = "rustfs_decommission_capacity_conflicts_total";
|
|
const METRIC_DECOMMISSION_CAPACITY_PREDICTED_BYTES: &str = "rustfs_decommission_capacity_predicted_physical_bytes";
|
|
const METRIC_DECOMMISSION_CAPACITY_RESERVED_BYTES: &str = "rustfs_decommission_capacity_reserved_physical_bytes";
|
|
const METRIC_DECOMMISSION_CAPACITY_PREDICTION_ERROR_BYTES: &str = "rustfs_decommission_capacity_prediction_error_bytes";
|
|
const METRIC_DECOMMISSION_CAPACITY_PREDICTION_ABSOLUTE_ERROR_BYTES: &str =
|
|
"rustfs_decommission_capacity_prediction_absolute_error_bytes";
|
|
const DECOMMISSION_LISTING_MAX_ATTEMPTS: usize = 3;
|
|
const DECOMMISSION_LISTING_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
|
pub(crate) const DECOMMISSION_ENTRY_MAX_ATTEMPTS: usize = 3;
|
|
const DECOMMISSION_CAPACITY_INTENT_CONFLICT_MAX_ATTEMPTS: usize = 12;
|
|
const DECOMMISSION_SOURCE_CLEANUP_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
|
|
pub(crate) const DECOMMISSION_VERSION_COPY_ATTEMPTS: usize = 3;
|
|
const DECOMMISSION_COPY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
|
|
const DECOMMISSION_SOURCE_CHANGED_EXHAUSTION_LIMIT: usize = 100;
|
|
const DECOMMISSION_TERMINAL_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
|
|
const DECOMMISSION_DURABLE_ILM_RECEIPT_ROOT: &str = "decommission/ilm-receipts";
|
|
const DECOMMISSION_DURABLE_ILM_MANIFEST_ROOT: &str = "decommission/ilm-manifests";
|
|
const DECOMMISSION_DURABLE_ILM_RECEIPT_SCHEMA: &str = "v2";
|
|
const DECOMMISSION_DURABLE_ILM_MANIFEST_SCHEMA: &str = "v1";
|
|
const DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE: usize = 16 * 1024;
|
|
const DECOMMISSION_DURABLE_ILM_MANIFEST_MAX_SIZE: usize = 4 * 1024;
|
|
const DECOMMISSION_DURABLE_ILM_RECEIPT_CAS_ATTEMPTS: usize = 3;
|
|
/// Background decommission walks must tolerate slow object migrations; the
|
|
/// stall timeout is the drive-health bound, not the total listing duration.
|
|
const DECOMMISSION_BACKGROUND_WALKDIR_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
|
|
|
pub const POOL_META_NAME: &str = "pool.bin";
|
|
pub(crate) const POOL_META_IDENTITY_NAME: &str = "pool.bin.identity";
|
|
pub const POOL_META_FORMAT: u16 = 1;
|
|
const POOL_META_V1_VERSION: u16 = 1;
|
|
pub const POOL_META_VERSION: u16 = 2;
|
|
const POOL_META_GENERATION_VERSION: u16 = 3;
|
|
const POOL_META_IDENTITY_FORMAT: u16 = 1;
|
|
const POOL_META_IDENTITY_VERSION: u16 = 1;
|
|
const POOL_META_INITIAL_EPOCH: u64 = 1;
|
|
const POOL_META_CAS_MAX_ATTEMPTS: usize = 3;
|
|
const METRIC_POOL_META_STALE_WRITE_REJECTIONS_TOTAL: &str = "rustfs_pool_meta_stale_write_rejections_total";
|
|
|
|
fn record_pool_meta_stale_write_rejection(reason: &'static str) {
|
|
metrics::counter!(METRIC_POOL_META_STALE_WRITE_REJECTIONS_TOTAL, "reason" => reason).increment(1);
|
|
}
|
|
|
|
fn pool_meta_v2_writer_enabled_for(requested: bool, fleet_confirmed: bool) -> bool {
|
|
requested && fleet_confirmed
|
|
}
|
|
|
|
fn pool_meta_v2_writer_enabled() -> bool {
|
|
pool_meta_v2_writer_enabled_for(
|
|
rustfs_utils::get_env_bool(rustfs_config::ENV_POOL_META_V2_WRITE, rustfs_config::DEFAULT_POOL_META_V2_WRITE),
|
|
rustfs_utils::get_env_bool(
|
|
rustfs_config::ENV_POOL_META_V2_FLEET_CONFIRMED,
|
|
rustfs_config::DEFAULT_POOL_META_V2_FLEET_CONFIRMED,
|
|
),
|
|
)
|
|
}
|
|
|
|
fn pool_meta_v3_writer_enabled_for(requested: bool, fleet_confirmed: bool) -> bool {
|
|
requested && fleet_confirmed
|
|
}
|
|
|
|
fn pool_meta_v3_writer_enabled() -> bool {
|
|
pool_meta_v3_writer_enabled_for(
|
|
rustfs_utils::get_env_bool(rustfs_config::ENV_POOL_META_V3_WRITE, rustfs_config::DEFAULT_POOL_META_V3_WRITE),
|
|
rustfs_utils::get_env_bool(
|
|
rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED,
|
|
rustfs_config::DEFAULT_POOL_META_V3_FLEET_CONFIRMED,
|
|
),
|
|
)
|
|
}
|
|
|
|
fn decommission_capacity_writer_supported_for(version: u16, v2_writer_enabled: bool, v3_writer_enabled: bool) -> bool {
|
|
matches!(version, POOL_META_VERSION | POOL_META_GENERATION_VERSION) || v2_writer_enabled || v3_writer_enabled
|
|
}
|
|
|
|
fn ensure_decommission_ledger_persistence_supported_for(
|
|
version: u16,
|
|
v2_writer_enabled: bool,
|
|
v3_writer_enabled: bool,
|
|
) -> Result<()> {
|
|
if decommission_capacity_writer_supported_for(version, v2_writer_enabled, v3_writer_enabled) {
|
|
return Ok(());
|
|
}
|
|
|
|
Err(Error::InvalidArgument(
|
|
"decommission".to_string(),
|
|
"pool-metadata-version".to_string(),
|
|
format!(
|
|
"durable unresolved-entry recovery requires pool metadata V2 or V3; enable either the {} + {} V2 gate or the {} + {} V3 gate only after every reader and writer supports that format",
|
|
rustfs_config::ENV_POOL_META_V2_WRITE,
|
|
rustfs_config::ENV_POOL_META_V2_FLEET_CONFIRMED,
|
|
rustfs_config::ENV_POOL_META_V3_WRITE,
|
|
rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED,
|
|
),
|
|
))
|
|
}
|
|
|
|
fn ensure_decommission_ledger_persistence_supported(pool_meta: &PoolMeta) -> Result<()> {
|
|
ensure_decommission_ledger_persistence_supported_for(
|
|
pool_meta.version,
|
|
pool_meta_v2_writer_enabled(),
|
|
pool_meta_v3_writer_enabled(),
|
|
)
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct DecommissionCanceler {
|
|
operation: Arc<DecommissionOperation>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct DecommissionOperation {
|
|
token: CancellationToken,
|
|
active: AtomicBool,
|
|
}
|
|
|
|
impl DecommissionCanceler {
|
|
pub(crate) fn new(token: CancellationToken) -> Self {
|
|
Self {
|
|
operation: Arc::new(DecommissionOperation {
|
|
token,
|
|
active: AtomicBool::new(true),
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn new_for_test(token: CancellationToken) -> Self {
|
|
Self::new(token)
|
|
}
|
|
|
|
fn token(&self) -> &CancellationToken {
|
|
&self.operation.token
|
|
}
|
|
|
|
pub(crate) fn is_active(&self) -> bool {
|
|
self.operation.active.load(Ordering::Acquire)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn is_cancelled(&self) -> bool {
|
|
self.token().is_cancelled()
|
|
}
|
|
|
|
fn cancel(&self) {
|
|
self.token().cancel();
|
|
}
|
|
|
|
fn release(&self) {
|
|
self.cancel();
|
|
self.operation.active.store(false, Ordering::Release);
|
|
}
|
|
|
|
fn owns_same_operation(&self, other: &Self) -> bool {
|
|
Arc::ptr_eq(&self.operation, &other.operation)
|
|
}
|
|
}
|
|
|
|
struct DecommissionCancelerGuard {
|
|
canceler: DecommissionCanceler,
|
|
}
|
|
|
|
impl DecommissionCancelerGuard {
|
|
fn new(canceler: DecommissionCanceler) -> Self {
|
|
Self { canceler }
|
|
}
|
|
|
|
fn canceler(&self) -> &DecommissionCanceler {
|
|
&self.canceler
|
|
}
|
|
}
|
|
|
|
impl Drop for DecommissionCancelerGuard {
|
|
fn drop(&mut self) {
|
|
self.canceler.release();
|
|
}
|
|
}
|
|
|
|
fn dedup_indices(indices: &[usize]) -> Vec<usize> {
|
|
let mut seen = HashSet::with_capacity(indices.len());
|
|
let mut output = Vec::with_capacity(indices.len());
|
|
for idx in indices {
|
|
if seen.insert(*idx) {
|
|
output.push(*idx);
|
|
}
|
|
}
|
|
|
|
output
|
|
}
|
|
|
|
fn bind_decommission_cancelers(
|
|
indices: &[usize],
|
|
parent: &CancellationToken,
|
|
cancelers: &mut [Option<DecommissionCanceler>],
|
|
) -> Vec<(usize, DecommissionCanceler)> {
|
|
let mut bound = Vec::with_capacity(indices.len());
|
|
|
|
for idx in indices {
|
|
if let Some(slot) = cancelers.get_mut(*idx) {
|
|
if let Some(existing) = slot.take() {
|
|
existing.release();
|
|
}
|
|
let canceler = DecommissionCanceler::new(parent.child_token());
|
|
*slot = Some(canceler.clone());
|
|
bound.push((*idx, canceler));
|
|
}
|
|
}
|
|
|
|
bound
|
|
}
|
|
|
|
fn bind_missing_decommission_cancelers(
|
|
indices: &[usize],
|
|
parent: &CancellationToken,
|
|
cancelers: &mut [Option<DecommissionCanceler>],
|
|
) -> Vec<(usize, DecommissionCanceler)> {
|
|
let mut bound = Vec::with_capacity(indices.len());
|
|
|
|
for idx in indices {
|
|
let Some(slot) = cancelers.get_mut(*idx) else {
|
|
continue;
|
|
};
|
|
if slot.as_ref().is_some_and(DecommissionCanceler::is_active) {
|
|
break;
|
|
}
|
|
if let Some(stale) = slot.take() {
|
|
stale.release();
|
|
}
|
|
let canceler = DecommissionCanceler::new(parent.child_token());
|
|
*slot = Some(canceler.clone());
|
|
bound.push((*idx, canceler));
|
|
}
|
|
|
|
bound
|
|
}
|
|
|
|
fn take_decommission_canceler(cancelers: &mut [Option<DecommissionCanceler>], idx: usize) -> Option<DecommissionCanceler> {
|
|
cancelers.get_mut(idx).and_then(Option::take)
|
|
}
|
|
|
|
fn take_decommission_canceler_for_operation(
|
|
cancelers: &mut [Option<DecommissionCanceler>],
|
|
idx: usize,
|
|
owner: &DecommissionCanceler,
|
|
) -> Option<DecommissionCanceler> {
|
|
let slot = cancelers.get_mut(idx)?;
|
|
if slot.as_ref().is_some_and(|canceler| canceler.owns_same_operation(owner)) {
|
|
slot.take()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn decommission_canceler_is_owned_by(
|
|
cancelers: &[Option<DecommissionCanceler>],
|
|
idx: usize,
|
|
owner: &DecommissionCanceler,
|
|
) -> bool {
|
|
cancelers
|
|
.get(idx)
|
|
.and_then(Option::as_ref)
|
|
.is_some_and(|canceler| canceler.owns_same_operation(owner))
|
|
}
|
|
|
|
fn update_decommission_for_operation<T>(
|
|
cancelers: &[Option<DecommissionCanceler>],
|
|
pool_meta: &mut PoolMeta,
|
|
idx: usize,
|
|
owner: Option<&DecommissionCanceler>,
|
|
update: impl FnOnce(&mut PoolMeta) -> T,
|
|
) -> Option<T> {
|
|
if let Some(owner) = owner
|
|
&& !decommission_canceler_is_owned_by(cancelers, idx, owner)
|
|
{
|
|
owner.release();
|
|
return None;
|
|
}
|
|
|
|
Some(update(pool_meta))
|
|
}
|
|
|
|
fn has_active_decommission_canceler(cancelers: &[Option<DecommissionCanceler>]) -> bool {
|
|
cancelers.iter().flatten().any(DecommissionCanceler::is_active)
|
|
}
|
|
|
|
fn cancel_decommission_canceler(canceler: Option<DecommissionCanceler>) -> bool {
|
|
if let Some(canceler) = canceler {
|
|
canceler.release();
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn take_and_cancel_decommission_canceler(cancelers: &mut [Option<DecommissionCanceler>], idx: usize) -> bool {
|
|
let canceler = take_decommission_canceler(cancelers, idx);
|
|
cancel_decommission_canceler(canceler)
|
|
}
|
|
|
|
fn take_and_cancel_decommission_canceler_for_operation(
|
|
cancelers: &mut [Option<DecommissionCanceler>],
|
|
idx: usize,
|
|
owner: &DecommissionCanceler,
|
|
) -> bool {
|
|
let canceler = take_decommission_canceler_for_operation(cancelers, idx, owner);
|
|
if canceler.is_none() {
|
|
owner.release();
|
|
return false;
|
|
}
|
|
cancel_decommission_canceler(canceler)
|
|
}
|
|
|
|
fn ensure_decommission_routines_scheduled(bound_count: usize, expected_count: usize) -> Result<()> {
|
|
if bound_count == 0 || bound_count != expected_count {
|
|
return Err(Error::other(format!(
|
|
"failed to start decommission routines: scheduled {bound_count} of {expected_count} expected workers"
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn guard_decommission_cancelers(index_cancelers: Vec<(usize, DecommissionCanceler)>) -> Vec<(usize, DecommissionCancelerGuard)> {
|
|
index_cancelers
|
|
.into_iter()
|
|
.map(|(idx, canceler)| (idx, DecommissionCancelerGuard::new(canceler)))
|
|
.collect()
|
|
}
|
|
|
|
async fn await_decommission_worker(idx: usize, worker: tokio::task::JoinHandle<Result<()>>) -> Result<()> {
|
|
worker
|
|
.await
|
|
.map_err(|err| Error::other(format!("decommission worker {idx} task join error: {err}")))?
|
|
}
|
|
|
|
fn reserve_decommission_start_cancelers(
|
|
pool_meta: &PoolMeta,
|
|
indices: &[usize],
|
|
local_indices: &[usize],
|
|
parent: &CancellationToken,
|
|
cancelers: &mut [Option<DecommissionCanceler>],
|
|
) -> Result<Vec<(usize, DecommissionCancelerGuard)>> {
|
|
ensure_decommission_start_pool_states(pool_meta, indices)?;
|
|
if local_indices.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let bound = bind_decommission_cancelers(local_indices, parent, cancelers);
|
|
let guards = guard_decommission_cancelers(bound);
|
|
ensure_decommission_routines_scheduled(guards.len(), local_indices.len())?;
|
|
Ok(guards)
|
|
}
|
|
|
|
fn default_decommission_bucket_concurrency(cpu_count: usize) -> usize {
|
|
cpu_count.clamp(1, DECOMMISSION_BUCKET_CONCURRENCY_DEFAULT_CAP)
|
|
}
|
|
|
|
fn decommission_bucket_concurrency_limit() -> usize {
|
|
let default_limit = default_decommission_bucket_concurrency(num_cpus::get());
|
|
rustfs_utils::get_env_usize(DECOMMISSION_BUCKET_CONCURRENCY_ENV, default_limit).max(1)
|
|
}
|
|
|
|
fn default_decommission_entry_concurrency(cpu_count: usize) -> usize {
|
|
cpu_count.clamp(1, DECOMMISSION_ENTRY_CONCURRENCY_DEFAULT_CAP)
|
|
}
|
|
|
|
fn clamp_decommission_entry_concurrency(limit: usize) -> usize {
|
|
limit.clamp(1, DECOMMISSION_ENTRY_CONCURRENCY_HARD_CAP)
|
|
}
|
|
|
|
fn decommission_entry_concurrency_limit() -> usize {
|
|
let default_limit = default_decommission_entry_concurrency(num_cpus::get());
|
|
clamp_decommission_entry_concurrency(rustfs_utils::get_env_usize(DECOMMISSION_ENTRY_CONCURRENCY_ENV, default_limit))
|
|
}
|
|
|
|
fn is_decommission_meta_bucket(bucket: &DecomBucketInfo) -> bool {
|
|
bucket.name == RUSTFS_META_BUCKET
|
|
}
|
|
|
|
fn decommission_meta_buckets() -> [DecomBucketInfo; DECOMMISSION_META_PREFIXES.len()] {
|
|
DECOMMISSION_META_PREFIXES.map(|prefix| DecomBucketInfo {
|
|
name: RUSTFS_META_BUCKET.to_owned(),
|
|
prefix: prefix.to_owned(),
|
|
})
|
|
}
|
|
|
|
fn reconcile_decommission_meta_buckets(meta: &mut PoolMeta, idx: usize) -> bool {
|
|
let before = meta.pending_buckets(idx).len();
|
|
meta.queue_buckets(idx, decommission_meta_buckets().into());
|
|
meta.pending_buckets(idx).len() != before
|
|
}
|
|
|
|
fn split_decommission_buckets(buckets: Vec<DecomBucketInfo>) -> (Vec<DecomBucketInfo>, Vec<DecomBucketInfo>) {
|
|
let mut regular = Vec::with_capacity(buckets.len());
|
|
let mut meta = Vec::new();
|
|
|
|
for bucket in buckets {
|
|
if is_decommission_meta_bucket(&bucket) {
|
|
meta.push(bucket);
|
|
} else {
|
|
regular.push(bucket);
|
|
}
|
|
}
|
|
|
|
regular.shrink_to_fit();
|
|
(regular, meta)
|
|
}
|
|
|
|
fn ensure_decommission_not_rebalancing(rebalance_running: bool) -> Result<()> {
|
|
if rebalance_running {
|
|
return Err(Error::RebalanceAlreadyRunning);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_decommission_start_rebalance_meta_allowed(meta: Option<&RebalanceMeta>) -> Result<()> {
|
|
ensure_decommission_not_rebalancing(meta.is_some_and(is_rebalance_conflicting_with_decommission))
|
|
}
|
|
|
|
#[allow(dead_code, reason = "leader precondition asserted by this file's tests (backlog#1823)")]
|
|
fn ensure_local_decommission_pool_leaders(endpoints: &EndpointServerPools, indices: &[usize]) -> Result<()> {
|
|
for idx in indices {
|
|
ensure_local_decommission_pool_leader(endpoints, *idx)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_local_decommission_pool_leader(endpoints: &EndpointServerPools, idx: usize) -> Result<()> {
|
|
let pool = endpoints
|
|
.as_ref()
|
|
.get(idx)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(endpoints.as_ref().len(), idx))?;
|
|
let endpoint = pool
|
|
.endpoints
|
|
.as_ref()
|
|
.first()
|
|
.ok_or_else(|| Error::other(format!("decommission pool {idx} has no configured endpoints")))?;
|
|
|
|
if !endpoint.is_local {
|
|
return Err(Error::other(format!(
|
|
"decommission for pool {idx} must run on the pool first endpoint {endpoint}"
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn decommission_pool_first_endpoint_is_local(endpoints: &EndpointServerPools, idx: usize) -> Result<bool> {
|
|
let pool = endpoints
|
|
.as_ref()
|
|
.get(idx)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(endpoints.as_ref().len(), idx))?;
|
|
let endpoint = pool
|
|
.endpoints
|
|
.as_ref()
|
|
.first()
|
|
.ok_or_else(|| Error::other(format!("decommission pool {idx} has no configured endpoints")))?;
|
|
|
|
Ok(endpoint.is_local)
|
|
}
|
|
|
|
pub(crate) fn local_decommission_queue_prefix(endpoints: &EndpointServerPools, indices: &[usize]) -> Result<Vec<usize>> {
|
|
let mut local = Vec::with_capacity(indices.len());
|
|
|
|
for idx in indices {
|
|
if decommission_pool_first_endpoint_is_local(endpoints, *idx)? {
|
|
local.push(*idx);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(local)
|
|
}
|
|
|
|
fn resumable_decommission_queue_indices(meta: &PoolMeta) -> Vec<usize> {
|
|
let mut indices = Vec::new();
|
|
for (idx, pool) in meta.pools.iter().enumerate() {
|
|
if let Some(decommission) = &pool.decommission {
|
|
if !decommission.has_decommission_state() {
|
|
continue;
|
|
}
|
|
if decommission.complete || decommission.failed || decommission.canceled {
|
|
continue;
|
|
}
|
|
indices.push(idx);
|
|
}
|
|
}
|
|
|
|
indices
|
|
}
|
|
|
|
fn missing_decommission_worker_prefix(indices: &[usize], cancelers: &[Option<DecommissionCanceler>]) -> Vec<usize> {
|
|
let mut missing = Vec::with_capacity(indices.len());
|
|
|
|
for idx in indices {
|
|
if cancelers
|
|
.get(*idx)
|
|
.and_then(Option::as_ref)
|
|
.is_some_and(DecommissionCanceler::is_active)
|
|
{
|
|
break;
|
|
}
|
|
missing.push(*idx);
|
|
}
|
|
|
|
missing
|
|
}
|
|
|
|
fn ensure_decommission_start_local_leader(endpoints: &EndpointServerPools, indices: &[usize]) -> Result<()> {
|
|
if let Some(first) = indices.first() {
|
|
ensure_local_decommission_pool_leader(endpoints, *first)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn build_decommission_start_state(
|
|
pi: PoolSpaceInfo,
|
|
queued: bool,
|
|
now: OffsetDateTime,
|
|
previous: Option<&PoolDecommissionInfo>,
|
|
) -> PoolDecommissionInfo {
|
|
let mut info = PoolDecommissionInfo {
|
|
start_time: if queued { None } else { Some(now) },
|
|
start_size: pi.free,
|
|
total_size: pi.total,
|
|
current_size: pi.free,
|
|
queued,
|
|
..Default::default()
|
|
};
|
|
|
|
if let Some(previous) = previous
|
|
&& (previous.failed || previous.canceled)
|
|
{
|
|
info.decommissioned_buckets = previous.decommissioned_buckets.clone();
|
|
info.items_decommissioned = previous.items_decommissioned;
|
|
info.bytes_done = previous.bytes_done;
|
|
info.unresolved_entries = previous.unresolved_entries.clone();
|
|
if let Some(generation) = info.start_time {
|
|
for entry in &mut info.unresolved_entries {
|
|
entry.source_generation = generation;
|
|
}
|
|
}
|
|
info.mark_progress_saved();
|
|
}
|
|
|
|
info
|
|
}
|
|
|
|
fn spawn_decommission_index_cancelers(
|
|
store: Arc<ECStore>,
|
|
rx: CancellationToken,
|
|
index_cancelers: Vec<(usize, DecommissionCancelerGuard)>,
|
|
entry_budget: Arc<Semaphore>,
|
|
) -> tokio::task::JoinHandle<()> {
|
|
tokio::spawn(async move {
|
|
let mut stop_queue = false;
|
|
|
|
for (idx, canceler_guard) in index_cancelers {
|
|
let canceler = canceler_guard.canceler().clone();
|
|
if stop_queue || rx.is_cancelled() {
|
|
canceler.cancel();
|
|
store.retry_decommission_cancel_for_operation(idx, &canceler).await;
|
|
continue;
|
|
}
|
|
|
|
let worker = tokio::spawn({
|
|
let store = store.clone();
|
|
let canceler = canceler.clone();
|
|
let entry_budget = entry_budget.clone();
|
|
async move { store.do_decommission_in_routine(canceler, idx, entry_budget).await }
|
|
});
|
|
if let Err(err) = await_decommission_worker(idx, worker).await {
|
|
if is_decommission_capacity_blocked_error(&err) || is_decommission_target_capacity_error(&err) {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "capacity_blocked",
|
|
error = %err,
|
|
"Decommission routine paused without a terminal transition"
|
|
);
|
|
store.release_decommission_canceler_slot(idx, &canceler).await;
|
|
stop_queue = true;
|
|
continue;
|
|
}
|
|
if let Err(blocked) = store
|
|
.ensure_pool_meta_side_effects_safe("decommission paused because pool metadata requires recovery")
|
|
.await
|
|
{
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "routine_blocked",
|
|
error = %blocked,
|
|
"Decommission routine paused without changing terminal state"
|
|
);
|
|
store.release_decommission_canceler_slot(idx, &canceler).await;
|
|
stop_queue = true;
|
|
continue;
|
|
}
|
|
error!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "routine_failed",
|
|
error = %err,
|
|
"Decommission routine failed"
|
|
);
|
|
store.quiesce_decommission_worker_after_join_error(&canceler).await;
|
|
store.retry_decommission_failed_for_operation(idx, &canceler).await;
|
|
stop_queue = true;
|
|
continue;
|
|
}
|
|
|
|
stop_queue = {
|
|
let pool_meta = store.pool_meta.read().await;
|
|
!should_continue_decommission_queue(&pool_meta, idx)
|
|
};
|
|
}
|
|
})
|
|
}
|
|
|
|
fn decommission_meta_bucket_options() -> MakeBucketOptions {
|
|
MakeBucketOptions {
|
|
force_create: true,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn is_decommission_active(complete: bool, failed: bool, canceled: bool) -> bool {
|
|
!complete && !failed && !canceled
|
|
}
|
|
|
|
pub(crate) 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() && is_decommission_active(info.complete, info.failed, info.canceled)
|
|
})
|
|
})
|
|
}
|
|
|
|
fn is_decommission_suspended(info: &PoolDecommissionInfo) -> bool {
|
|
info.has_decommission_state()
|
|
}
|
|
|
|
fn validate_decommission_terminal_state(complete: bool, failed: bool, canceled: bool) -> Result<()> {
|
|
let terminal_count = [complete, failed, canceled].into_iter().filter(|terminal| *terminal).count();
|
|
if terminal_count > 1 {
|
|
return Err(Error::other(format!(
|
|
"pool metadata load failed: invalid decommission terminal state complete={complete} failed={failed} canceled={canceled}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn invalid_decommission_pool_index_error(pool_count: usize, idx: usize) -> Error {
|
|
Error::other(format!("invalid decommission pool index {idx} for {pool_count} pools"))
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum DecommissionStartPoolState {
|
|
Missing,
|
|
Active,
|
|
Retryable,
|
|
Decommissioning,
|
|
Decommissioned,
|
|
Blocked,
|
|
}
|
|
|
|
fn decommission_start_pool_state(pool: Option<&PoolStatus>) -> DecommissionStartPoolState {
|
|
let Some(pool) = pool else {
|
|
return DecommissionStartPoolState::Missing;
|
|
};
|
|
let Some(info) = pool.decommission.as_ref() else {
|
|
return DecommissionStartPoolState::Active;
|
|
};
|
|
if !info.has_decommission_state() {
|
|
return DecommissionStartPoolState::Active;
|
|
}
|
|
|
|
if info.complete {
|
|
DecommissionStartPoolState::Decommissioned
|
|
} else if info.failed || info.canceled {
|
|
if info.unresolved_entries.is_empty() {
|
|
DecommissionStartPoolState::Blocked
|
|
} else {
|
|
DecommissionStartPoolState::Retryable
|
|
}
|
|
} else {
|
|
DecommissionStartPoolState::Decommissioning
|
|
}
|
|
}
|
|
|
|
fn is_decommission_start_active_pool(pool: &PoolStatus) -> bool {
|
|
decommission_start_pool_state(Some(pool)) == DecommissionStartPoolState::Active
|
|
}
|
|
|
|
fn ensure_decommission_start_allowed(state: DecommissionStartPoolState) -> Result<()> {
|
|
match state {
|
|
DecommissionStartPoolState::Missing => Err(Error::other("failed to start decommission: target pool was not found")),
|
|
DecommissionStartPoolState::Active | DecommissionStartPoolState::Retryable => Ok(()),
|
|
DecommissionStartPoolState::Decommissioning => Err(StorageError::DecommissionAlreadyRunning),
|
|
DecommissionStartPoolState::Decommissioned => {
|
|
Err(Error::other("failed to start decommission: target pool is already decommissioned"))
|
|
}
|
|
DecommissionStartPoolState::Blocked => Err(Error::other(
|
|
"failed to start decommission: target pool decommission is blocked; clear failed or canceled metadata before starting again",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn ensure_decommission_start_keeps_active_pool(meta: &PoolMeta, indices: &[usize]) -> Result<()> {
|
|
let active_count = meta
|
|
.pools
|
|
.iter()
|
|
.filter(|pool| is_decommission_start_active_pool(pool))
|
|
.count();
|
|
let active_target_count = indices
|
|
.iter()
|
|
.filter(|idx| meta.pools.get(**idx).is_some_and(is_decommission_start_active_pool))
|
|
.count();
|
|
if active_count.saturating_sub(active_target_count) == 0 {
|
|
return Err(Error::other(
|
|
"failed to start decommission: at least one active pool must remain after decommission start",
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_decommission_start_pool_states(meta: &PoolMeta, indices: &[usize]) -> Result<()> {
|
|
for idx in indices.iter().copied() {
|
|
ensure_decommission_start_allowed(decommission_start_pool_state(meta.pools.get(idx)))?;
|
|
}
|
|
ensure_decommission_start_keeps_active_pool(meta, indices)
|
|
}
|
|
|
|
fn capacity_mul_div_ceil(value: usize, multiplier: usize, divisor: usize) -> usize {
|
|
if value == 0 || multiplier == 0 {
|
|
return 0;
|
|
}
|
|
if divisor == 0 {
|
|
return usize::MAX;
|
|
}
|
|
value
|
|
.checked_mul(multiplier)
|
|
.map(|product| product.div_ceil(divisor))
|
|
.unwrap_or(usize::MAX)
|
|
}
|
|
|
|
fn capacity_source_data_equivalent(physical_bytes: usize, layout: DecommissionErasureLayout) -> Result<usize> {
|
|
if !layout.is_valid() {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"failed to model decommission capacity: invalid source layout data={} parity={}",
|
|
layout.data, layout.parity
|
|
)));
|
|
}
|
|
Ok(capacity_mul_div_ceil(physical_bytes, layout.data, layout.width()))
|
|
}
|
|
|
|
fn capacity_target_physical_bytes(data_bytes: usize, layout: DecommissionErasureLayout) -> Result<usize> {
|
|
if !layout.is_valid() {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"failed to model decommission capacity: invalid target layout data={} parity={}",
|
|
layout.data, layout.parity
|
|
)));
|
|
}
|
|
Ok(capacity_mul_div_ceil(data_bytes, layout.width(), layout.data))
|
|
}
|
|
|
|
fn worst_decommission_target_layout(targets: &[DecommissionPoolCapacityInfo]) -> Result<DecommissionErasureLayout> {
|
|
targets
|
|
.iter()
|
|
.map(|target| target.layout)
|
|
.filter(|layout| layout.is_valid())
|
|
.max_by(|left, right| {
|
|
((left.width() as u128) * (right.data as u128)).cmp(&((right.width() as u128) * (left.data as u128)))
|
|
})
|
|
.ok_or_else(|| Error::other("failed to start decommission: no valid target erasure layout is available"))
|
|
}
|
|
|
|
fn decommission_capacity_writer_supported(meta: &PoolMeta) -> bool {
|
|
decommission_capacity_writer_supported_for(meta.version, pool_meta_v2_writer_enabled(), pool_meta_v3_writer_enabled())
|
|
}
|
|
|
|
fn ensure_decommission_capacity_writer_supported(meta: &PoolMeta) -> Result<()> {
|
|
if decommission_capacity_writer_supported(meta) {
|
|
return Ok(());
|
|
}
|
|
Err(Error::DecommissionCapacity(format!(
|
|
"failed to start decommission: durable capacity reservations require pool metadata V2 or V3; enable either the {} + {} V2 gate or the {} + {} V3 gate only after every reader and writer supports that format",
|
|
rustfs_config::ENV_POOL_META_V2_WRITE,
|
|
rustfs_config::ENV_POOL_META_V2_FLEET_CONFIRMED,
|
|
rustfs_config::ENV_POOL_META_V3_WRITE,
|
|
rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED,
|
|
)))
|
|
}
|
|
|
|
fn decommission_capacity_blocked_error(message: impl Display) -> Error {
|
|
Error::DecommissionCapacityBlocked {
|
|
message: message.to_string(),
|
|
}
|
|
}
|
|
|
|
fn is_decommission_capacity_blocked_error(err: &Error) -> bool {
|
|
if matches!(err, Error::DecommissionCapacityBlocked { .. }) {
|
|
return true;
|
|
}
|
|
data_movement::data_movement_stage_source(err).is_some_and(is_decommission_capacity_blocked_error)
|
|
}
|
|
|
|
fn is_decommission_capacity_intent_conflict(err: &Error) -> bool {
|
|
if let Error::DecommissionCapacityBlocked { message } = err {
|
|
return message.contains("unresolved target capacity intent")
|
|
|| message.contains("pending capacity intent belongs to another mutation");
|
|
}
|
|
data_movement::data_movement_stage_source(err).is_some_and(is_decommission_capacity_intent_conflict)
|
|
|| err.to_string().contains("unresolved target capacity intent")
|
|
}
|
|
|
|
fn decommission_capacity_target_gate_busy_index(err: &Error) -> Option<usize> {
|
|
if let Error::DecommissionCapacityBlocked { message } = err {
|
|
return message
|
|
.strip_prefix(DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_PREFIX)?
|
|
.strip_suffix(DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_SUFFIX)?
|
|
.parse()
|
|
.ok();
|
|
}
|
|
data_movement::data_movement_stage_source(err).and_then(decommission_capacity_target_gate_busy_index)
|
|
}
|
|
|
|
fn is_decommission_capacity_target_gate_busy(err: &Error) -> bool {
|
|
decommission_capacity_target_gate_busy_index(err).is_some()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum DecommissionCapacityRetryKind {
|
|
IntentConflict,
|
|
}
|
|
|
|
fn decommission_capacity_retry_kind(err: &Error, intent_conflict_attempt: usize) -> Option<DecommissionCapacityRetryKind> {
|
|
(intent_conflict_attempt < DECOMMISSION_CAPACITY_INTENT_CONFLICT_MAX_ATTEMPTS
|
|
&& is_decommission_capacity_intent_conflict(err))
|
|
.then_some(DecommissionCapacityRetryKind::IntentConflict)
|
|
}
|
|
|
|
fn ensure_decommission_capacity_target_fence(
|
|
guard: &rustfs_lock::NamespaceLockGuard,
|
|
target_pool_index: usize,
|
|
phase: &str,
|
|
) -> Result<()> {
|
|
if guard.is_lock_lost() {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"target pool {target_pool_index} capacity mutation fence was lost during {phase}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_decommission_capacity_mutation_intent_current(
|
|
meta: &PoolMeta,
|
|
owner: DecommissionCapacityOwner,
|
|
target_pool_index: usize,
|
|
expected_target_physical_bytes: usize,
|
|
mutation_id: uuid::Uuid,
|
|
temporary_release: bool,
|
|
model_version: u16,
|
|
) -> Result<()> {
|
|
let reservation = meta
|
|
.pools
|
|
.get(owner.source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| {
|
|
// Admission validated the owner nonce before persisting this
|
|
// intent. The target lock is the mutation fence after that point,
|
|
// so a concurrent lease renewal may rotate the nonce without
|
|
// invalidating the already-durable intent.
|
|
reservation.active()
|
|
&& reservation.source_pool_index == owner.source_pool_index
|
|
&& reservation.operation_id == owner.operation_id
|
|
&& reservation.generation == owner.generation
|
|
&& reservation.model_version == model_version
|
|
})
|
|
.ok_or_else(|| {
|
|
decommission_capacity_blocked_error("decommission target mutation owner changed before capacity finalize")
|
|
})?;
|
|
let target = reservation
|
|
.targets
|
|
.iter()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("decommission target allocation changed before capacity finalize"))?;
|
|
|
|
if temporary_release {
|
|
// Cleanup is authorized by the persisted owner and target identity.
|
|
// Its exact temporary or pending record may already be absent after a
|
|
// prior successful finalize, so attribution is decided from the
|
|
// physical cleanup result below instead of rejecting the retry here.
|
|
return Ok(());
|
|
}
|
|
|
|
if expected_target_physical_bytes == 0
|
|
|| (target.pending_mutation_id == Some(mutation_id) && target.pending_physical_bytes >= expected_target_physical_bytes)
|
|
{
|
|
return Ok(());
|
|
}
|
|
Err(decommission_capacity_blocked_error(
|
|
"pending capacity intent belongs to another mutation before capacity finalize",
|
|
))
|
|
}
|
|
|
|
fn validate_decommission_capacity_reservation(reservation: Option<&DecommissionCapacityReservation>) -> Result<()> {
|
|
let Some(reservation) = reservation else {
|
|
return Ok(());
|
|
};
|
|
if !matches!(
|
|
reservation.model_version,
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION | DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION
|
|
) {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"pool metadata load failed: unsupported decommission capacity model version {}",
|
|
reservation.model_version
|
|
)));
|
|
}
|
|
if reservation.operation_id.is_nil() || reservation.generation == 0 || reservation.owner_nonce.is_nil() {
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity reservation identity is invalid",
|
|
));
|
|
}
|
|
if !reservation.source_layout.is_valid() || reservation.targets.iter().any(|target| !target.layout.is_valid()) {
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity reservation contains an invalid erasure layout",
|
|
));
|
|
}
|
|
if reservation.temporary_copies != DECOMMISSION_CAPACITY_TEMPORARY_COPIES {
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity reservation temporary-copy model is invalid",
|
|
));
|
|
}
|
|
if reservation.source_data_equivalent_bytes
|
|
!= capacity_source_data_equivalent(reservation.source_physical_bytes, reservation.source_layout)?
|
|
{
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity reservation source estimate is invalid",
|
|
));
|
|
}
|
|
if reservation.peak_physical_bytes
|
|
!= reservation
|
|
.predicted_physical_bytes
|
|
.saturating_add(reservation.temporary_physical_bytes)
|
|
|| reservation.temporary_physical_bytes
|
|
!= reservation
|
|
.predicted_physical_bytes
|
|
.saturating_mul(reservation.temporary_copies)
|
|
{
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity reservation peak does not match its physical model",
|
|
));
|
|
}
|
|
if reservation.committed_data_bytes > reservation.source_data_equivalent_bytes {
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity committed bytes exceed the source estimate",
|
|
));
|
|
}
|
|
if reservation.consumed_target_physical_bytes > reservation.predicted_physical_bytes {
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity consumed target bytes exceed the prediction",
|
|
));
|
|
}
|
|
let mut target_indices = HashSet::with_capacity(reservation.targets.len());
|
|
let mut reserved_physical_bytes = 0usize;
|
|
let mut consumed_physical_bytes = 0usize;
|
|
let mut observed_physical_bytes = 0usize;
|
|
let mut inflight_physical_bytes = 0usize;
|
|
let mut pending_physical_bytes = 0usize;
|
|
for target in &reservation.targets {
|
|
let mut temporary_mutation_ids = HashSet::with_capacity(target.temporary_mutations.len());
|
|
let temporary_mutation_bytes = target.temporary_mutations.iter().try_fold(0usize, |total, mutation| {
|
|
if mutation.mutation_id.is_nil()
|
|
|| (mutation.physical_bytes == 0 && reservation.model_version != DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION)
|
|
|| !temporary_mutation_ids.insert(mutation.mutation_id)
|
|
{
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity temporary mutation is invalid",
|
|
));
|
|
}
|
|
Ok(total.saturating_add(mutation.physical_bytes))
|
|
})?;
|
|
if target.pool_index == reservation.source_pool_index
|
|
|| !target_indices.insert(target.pool_index)
|
|
|| target.reserved_physical_bytes == 0
|
|
|| target.reserved_physical_bytes > target.physical_free_at_reservation
|
|
|| target.physical_free_at_reservation > target.physical_total_at_reservation
|
|
|| target.consumed_physical_bytes > reservation.consumed_target_physical_bytes
|
|
|| target.observed_physical_bytes > reservation.observed_target_physical_bytes
|
|
|| target.inflight_physical_bytes > target.observed_physical_bytes
|
|
|| temporary_mutation_bytes > target.inflight_physical_bytes
|
|
{
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity reservation target allocation is invalid",
|
|
));
|
|
}
|
|
if reservation.predicted_physical_bytes
|
|
< capacity_target_physical_bytes(reservation.source_data_equivalent_bytes, target.layout)?
|
|
{
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity reservation target estimate is unsafe",
|
|
));
|
|
}
|
|
reserved_physical_bytes =
|
|
reserved_physical_bytes.saturating_add(target.remaining_reserved_physical_bytes(reservation.temporary_copies));
|
|
consumed_physical_bytes = consumed_physical_bytes.saturating_add(target.consumed_physical_bytes);
|
|
observed_physical_bytes = observed_physical_bytes.saturating_add(target.observed_physical_bytes);
|
|
inflight_physical_bytes = inflight_physical_bytes.saturating_add(target.inflight_physical_bytes);
|
|
pending_physical_bytes = pending_physical_bytes.saturating_add(target.pending_physical_bytes);
|
|
}
|
|
if reserved_physical_bytes != reservation.remaining_peak_physical_bytes() {
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity reservation target allocation is incomplete",
|
|
));
|
|
}
|
|
if inflight_physical_bytes != reservation.inflight_target_physical_bytes {
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity inflight target accounting is incomplete",
|
|
));
|
|
}
|
|
if pending_physical_bytes != reservation.pending_target_physical_bytes {
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity pending target accounting is incomplete",
|
|
));
|
|
}
|
|
if consumed_physical_bytes != reservation.consumed_target_physical_bytes
|
|
|| observed_physical_bytes != reservation.observed_target_physical_bytes
|
|
{
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity target progress accounting is incomplete",
|
|
));
|
|
}
|
|
if reservation.released_at.is_some() != reservation.release_reason.is_some() {
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity reservation release state is incomplete",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn active_decommission_capacity_model(meta: &PoolMeta) -> Result<Option<u16>> {
|
|
let mut active_model = None;
|
|
for reservation in meta
|
|
.pools
|
|
.iter()
|
|
.filter_map(|pool| pool.decommission.as_ref()?.capacity_reservation.as_ref())
|
|
.filter(|reservation| reservation.active())
|
|
{
|
|
if !matches!(
|
|
reservation.model_version,
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION | DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION
|
|
) {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"pool metadata load failed: unsupported active decommission capacity model version {}",
|
|
reservation.model_version
|
|
)));
|
|
}
|
|
match active_model {
|
|
Some(model_version) if model_version != reservation.model_version => {
|
|
return Err(Error::DecommissionCapacity(
|
|
"pool metadata load failed: active decommission capacity reservations use mixed lock models".to_string(),
|
|
));
|
|
}
|
|
Some(_) => {}
|
|
None => active_model = Some(reservation.model_version),
|
|
}
|
|
}
|
|
Ok(active_model)
|
|
}
|
|
|
|
fn validate_decommission_capacity_model_cohort(meta: &PoolMeta) -> Result<()> {
|
|
active_decommission_capacity_model(meta).map(|_| ())
|
|
}
|
|
|
|
fn select_decommission_capacity_model(meta: &PoolMeta, target_fence_proof_available: bool) -> Result<u16> {
|
|
match active_decommission_capacity_model(meta)? {
|
|
Some(DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION) => Ok(DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION),
|
|
Some(DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION) if target_fence_proof_available => {
|
|
Ok(DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION)
|
|
}
|
|
Some(DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION) => Err(Error::DecommissionCapacity(
|
|
"failed to start decommission: an active per-target capacity cohort requires a live all-v4 fleet proof".to_string(),
|
|
)),
|
|
Some(version) => Err(Error::DecommissionCapacity(format!(
|
|
"failed to start decommission: unsupported active capacity model version {version}"
|
|
))),
|
|
None if target_fence_proof_available => Ok(DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION),
|
|
None => Ok(DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION),
|
|
}
|
|
}
|
|
|
|
fn ensure_decommission_target_fence_fleet_proof(
|
|
proof: Option<&crate::services::notification_sys::DecommissionTargetFenceFleetProofToken>,
|
|
required: bool,
|
|
) -> Result<()> {
|
|
if !required {
|
|
return Ok(());
|
|
}
|
|
if proof.is_some_and(crate::services::notification_sys::decommission_target_fence_fleet_proof_matches) {
|
|
return Ok(());
|
|
}
|
|
Err(Error::other(DECOMMISSION_TARGET_FLEET_PROOF_EXPIRED))
|
|
}
|
|
|
|
fn release_decommission_capacity_reservation(info: &mut PoolDecommissionInfo, reason: &str, now: OffsetDateTime) -> bool {
|
|
let Some(reservation) = info.capacity_reservation.as_mut() else {
|
|
return false;
|
|
};
|
|
if !reservation.active() {
|
|
return false;
|
|
}
|
|
reservation.released_at = Some(now);
|
|
reservation.release_reason = Some(reason.to_string());
|
|
metrics::gauge!(
|
|
METRIC_DECOMMISSION_CAPACITY_RESERVED_BYTES,
|
|
"pool_index" => reservation.source_pool_index.to_string()
|
|
)
|
|
.set(0.0);
|
|
true
|
|
}
|
|
|
|
fn renew_decommission_capacity_reservation(
|
|
reservation: &mut DecommissionCapacityReservation,
|
|
now: OffsetDateTime,
|
|
recover_expired: bool,
|
|
) -> bool {
|
|
if !reservation.active() {
|
|
return false;
|
|
}
|
|
let expired = reservation.expires_at <= now;
|
|
if expired && recover_expired {
|
|
reservation.owner_nonce = uuid::Uuid::new_v4();
|
|
reservation.recovered_at = Some(now);
|
|
}
|
|
reservation.renewed_at = now;
|
|
reservation.expires_at = now + DECOMMISSION_CAPACITY_RESERVATION_TTL;
|
|
true
|
|
}
|
|
|
|
fn next_decommission_capacity_generation(meta: &PoolMeta) -> Result<u64> {
|
|
meta.pools
|
|
.iter()
|
|
.filter_map(|pool| pool.decommission.as_ref()?.capacity_reservation.as_ref())
|
|
.map(|reservation| reservation.generation)
|
|
.max()
|
|
.unwrap_or_default()
|
|
.checked_add(1)
|
|
.ok_or_else(|| Error::other("failed to start decommission: capacity reservation generation overflow"))
|
|
}
|
|
|
|
fn active_decommission_source_indices(meta: &PoolMeta) -> HashSet<usize> {
|
|
meta.pools
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(idx, pool)| {
|
|
pool.decommission.as_ref().and_then(|info| {
|
|
(info.has_decommission_state() && is_decommission_active(info.complete, info.failed, info.canceled))
|
|
.then_some(idx)
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn build_decommission_capacity_reservation(
|
|
source: DecommissionPoolCapacityInfo,
|
|
target_layout: DecommissionErasureLayout,
|
|
operation_id: uuid::Uuid,
|
|
generation: u64,
|
|
now: OffsetDateTime,
|
|
) -> Result<DecommissionCapacityReservation> {
|
|
build_decommission_capacity_reservation_with_model(
|
|
source,
|
|
target_layout,
|
|
operation_id,
|
|
generation,
|
|
now,
|
|
DECOMMISSION_CAPACITY_MODEL_VERSION,
|
|
)
|
|
}
|
|
|
|
fn build_decommission_capacity_reservation_with_model(
|
|
source: DecommissionPoolCapacityInfo,
|
|
target_layout: DecommissionErasureLayout,
|
|
operation_id: uuid::Uuid,
|
|
generation: u64,
|
|
now: OffsetDateTime,
|
|
model_version: u16,
|
|
) -> Result<DecommissionCapacityReservation> {
|
|
if !matches!(
|
|
model_version,
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION | DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION
|
|
) {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"failed to build decommission capacity reservation: unsupported lock model {model_version}"
|
|
)));
|
|
}
|
|
let source_data_equivalent_bytes = capacity_source_data_equivalent(source.physical_used, source.layout)?;
|
|
let predicted_physical_bytes = capacity_target_physical_bytes(source_data_equivalent_bytes, target_layout)?;
|
|
let temporary_physical_bytes = predicted_physical_bytes.saturating_mul(DECOMMISSION_CAPACITY_TEMPORARY_COPIES);
|
|
Ok(DecommissionCapacityReservation {
|
|
model_version,
|
|
operation_id,
|
|
generation,
|
|
owner_nonce: uuid::Uuid::new_v4(),
|
|
source_pool_index: source.pool_index,
|
|
source_layout: source.layout,
|
|
source_physical_total_bytes: source.physical_total,
|
|
source_physical_bytes: source.physical_used,
|
|
source_data_equivalent_bytes,
|
|
predicted_physical_bytes,
|
|
temporary_copies: DECOMMISSION_CAPACITY_TEMPORARY_COPIES,
|
|
temporary_physical_bytes,
|
|
peak_physical_bytes: predicted_physical_bytes.saturating_add(temporary_physical_bytes),
|
|
committed_data_bytes: 0,
|
|
consumed_target_physical_bytes: 0,
|
|
observed_target_physical_bytes: 0,
|
|
inflight_target_physical_bytes: 0,
|
|
pending_target_physical_bytes: 0,
|
|
prediction_error_bytes: 0,
|
|
targets: Vec::new(),
|
|
created_at: now,
|
|
renewed_at: now,
|
|
expires_at: now + DECOMMISSION_CAPACITY_RESERVATION_TTL,
|
|
recovered_at: None,
|
|
released_at: None,
|
|
release_reason: None,
|
|
})
|
|
}
|
|
|
|
fn reserve_decommission_start_target_capacity(
|
|
meta: &mut PoolMeta,
|
|
requested_indices: &[usize],
|
|
capacity_infos: &[DecommissionPoolCapacityInfo],
|
|
operation_id: uuid::Uuid,
|
|
generation: u64,
|
|
now: OffsetDateTime,
|
|
model_version: u16,
|
|
) -> Result<()> {
|
|
validate_decommission_capacity_model_cohort(meta)?;
|
|
let active_sources = active_decommission_source_indices(meta);
|
|
let targets = capacity_infos
|
|
.iter()
|
|
.copied()
|
|
.filter(|capacity| {
|
|
!active_sources.contains(&capacity.pool_index)
|
|
&& meta
|
|
.pools
|
|
.get(capacity.pool_index)
|
|
.is_some_and(is_decommission_start_active_pool)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let target_layout = worst_decommission_target_layout(&targets)?;
|
|
let requested = requested_indices.iter().copied().collect::<HashSet<_>>();
|
|
let mut reservations = Vec::with_capacity(active_sources.len());
|
|
|
|
let mut source_indices = active_sources.into_iter().collect::<Vec<_>>();
|
|
source_indices.sort_unstable();
|
|
for source_index in source_indices {
|
|
let source = capacity_infos
|
|
.iter()
|
|
.copied()
|
|
.find(|capacity| capacity.pool_index == source_index)
|
|
.ok_or_else(|| {
|
|
Error::DecommissionCapacity(format!(
|
|
"failed to start decommission: capacity snapshot is missing source pool {source_index}"
|
|
))
|
|
})?;
|
|
let pool = meta
|
|
.pools
|
|
.get(source_index)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(meta.pools.len(), source_index))?;
|
|
let info = pool
|
|
.decommission
|
|
.as_ref()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("reserve decommission capacity"))?;
|
|
let new_reservation = requested.contains(&source_index);
|
|
let mut reservation = if new_reservation {
|
|
build_decommission_capacity_reservation_with_model(
|
|
source,
|
|
target_layout,
|
|
operation_id,
|
|
generation,
|
|
now,
|
|
model_version,
|
|
)?
|
|
} else {
|
|
info.capacity_reservation.clone().ok_or_else(|| {
|
|
Error::DecommissionCapacity(format!(
|
|
"failed to start decommission: active source pool {source_index} has no durable capacity reservation"
|
|
))
|
|
})?
|
|
};
|
|
if !reservation.active() {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"failed to start decommission: active source pool {source_index} has a released capacity reservation"
|
|
)));
|
|
}
|
|
renew_decommission_capacity_reservation(&mut reservation, now, true);
|
|
reservations.push((source_index, reservation, new_reservation));
|
|
}
|
|
|
|
let mut remaining_target_capacity = targets
|
|
.iter()
|
|
.map(|target| (target.pool_index, target.physical_free))
|
|
.collect::<HashMap<_, _>>();
|
|
for (_, reservation, new_reservation) in &reservations {
|
|
if *new_reservation {
|
|
continue;
|
|
}
|
|
for target in &reservation.targets {
|
|
let available = remaining_target_capacity.entry(target.pool_index).or_default();
|
|
*available = available
|
|
.saturating_add(target.inflight_physical_bytes)
|
|
.saturating_add(target.pending_physical_bytes);
|
|
let required = target.remaining_reserved_physical_bytes(reservation.temporary_copies);
|
|
if *available < required {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"failed to start decommission: existing reservation for source pool {} requires {required} bytes on target pool {}, but only {} bytes remain",
|
|
reservation.source_pool_index, target.pool_index, *available
|
|
)));
|
|
}
|
|
*available -= required;
|
|
}
|
|
}
|
|
let required = reservations
|
|
.iter()
|
|
.filter(|(_, _, new_reservation)| *new_reservation)
|
|
.fold(0usize, |total, (_, reservation, _)| {
|
|
total.saturating_add(reservation.remaining_peak_physical_bytes())
|
|
});
|
|
let available = remaining_target_capacity
|
|
.values()
|
|
.copied()
|
|
.fold(0usize, usize::saturating_add);
|
|
if available < required {
|
|
metrics::counter!(METRIC_DECOMMISSION_CAPACITY_CONFLICTS_TOTAL, "phase" => "activation").increment(1);
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"failed to start decommission: insufficient reserved physical target capacity: operation {operation_id} generation {generation} requires {required} bytes, but {available} bytes are available after source/target parity and temporary-copy accounting"
|
|
)));
|
|
}
|
|
|
|
for (_, reservation, new_reservation) in &mut reservations {
|
|
if !*new_reservation {
|
|
continue;
|
|
}
|
|
let mut remaining = reservation.remaining_peak_physical_bytes();
|
|
for target in &targets {
|
|
if remaining == 0 {
|
|
break;
|
|
}
|
|
let target_remaining = remaining_target_capacity.entry(target.pool_index).or_default();
|
|
let allocated = remaining.min(*target_remaining);
|
|
if allocated == 0 {
|
|
continue;
|
|
}
|
|
*target_remaining -= allocated;
|
|
remaining -= allocated;
|
|
reservation.targets.push(DecommissionCapacityTarget {
|
|
pool_index: target.pool_index,
|
|
layout: target.layout,
|
|
physical_total_at_reservation: target.physical_total,
|
|
physical_free_at_reservation: target.physical_free,
|
|
reserved_physical_bytes: allocated,
|
|
consumed_physical_bytes: 0,
|
|
observed_physical_bytes: 0,
|
|
inflight_physical_bytes: 0,
|
|
pending_physical_bytes: 0,
|
|
pending_mutation_id: None,
|
|
temporary_mutations: Vec::new(),
|
|
});
|
|
}
|
|
debug_assert_eq!(remaining, 0);
|
|
}
|
|
|
|
for (source_index, reservation, _) in reservations {
|
|
metrics::gauge!(
|
|
METRIC_DECOMMISSION_CAPACITY_PREDICTED_BYTES,
|
|
"pool_index" => source_index.to_string()
|
|
)
|
|
.set(reservation.predicted_physical_bytes as f64);
|
|
metrics::gauge!(
|
|
METRIC_DECOMMISSION_CAPACITY_RESERVED_BYTES,
|
|
"pool_index" => source_index.to_string()
|
|
)
|
|
.set(reservation.remaining_peak_physical_bytes() as f64);
|
|
let pool_count = meta.pools.len();
|
|
let info = meta
|
|
.pools
|
|
.get_mut(source_index)
|
|
.and_then(|pool| pool.decommission.as_mut())
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_index))?;
|
|
info.capacity_blocked_reason = None;
|
|
info.capacity_reservation = Some(reservation);
|
|
}
|
|
if meta.version != POOL_META_GENERATION_VERSION {
|
|
meta.version = POOL_META_VERSION;
|
|
}
|
|
validate_decommission_capacity_model_cohort(meta)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_decommission_start_target_capacity(
|
|
meta: &PoolMeta,
|
|
indices: &[usize],
|
|
capacity_infos: &[DecommissionPoolCapacityInfo],
|
|
target_fence_proof_available: bool,
|
|
) -> Result<()> {
|
|
let generation = next_decommission_capacity_generation(meta)?;
|
|
let mut projected = meta.clone();
|
|
let first_idx = indices.first().copied();
|
|
for idx in indices.iter().copied() {
|
|
let capacity = capacity_infos
|
|
.iter()
|
|
.find(|capacity| capacity.pool_index == idx)
|
|
.ok_or_else(|| Error::DecommissionCapacity(format!("decommission capacity snapshot is missing pool {idx}")))?;
|
|
if Some(idx) == first_idx {
|
|
projected.decommission(idx, capacity.space)?;
|
|
} else {
|
|
projected.queue_decommission(idx, capacity.space)?;
|
|
}
|
|
}
|
|
let model_version = select_decommission_capacity_model(&projected, target_fence_proof_available)?;
|
|
reserve_decommission_start_target_capacity(
|
|
&mut projected,
|
|
indices,
|
|
capacity_infos,
|
|
uuid::Uuid::new_v4(),
|
|
generation,
|
|
OffsetDateTime::now_utc(),
|
|
model_version,
|
|
)
|
|
}
|
|
|
|
fn recover_decommission_capacity_reservations(
|
|
meta: &mut PoolMeta,
|
|
capacity_infos: &[DecommissionPoolCapacityInfo],
|
|
now: OffsetDateTime,
|
|
target_fence_proof_available: bool,
|
|
) -> Result<Vec<usize>> {
|
|
ensure_decommission_capacity_writer_supported(meta)?;
|
|
let mut active_indices = active_decommission_source_indices(meta).into_iter().collect::<Vec<_>>();
|
|
active_indices.sort_unstable();
|
|
let missing_indices = active_indices
|
|
.iter()
|
|
.copied()
|
|
.filter(|idx| {
|
|
meta.pools
|
|
.get(*idx)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.is_none_or(|reservation| !reservation.active())
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if missing_indices.is_empty() {
|
|
validate_decommission_capacity_model_cohort(meta)?;
|
|
return Ok(active_indices);
|
|
}
|
|
let generation = next_decommission_capacity_generation(meta)?;
|
|
let model_version = select_decommission_capacity_model(meta, target_fence_proof_available)?;
|
|
reserve_decommission_start_target_capacity(
|
|
meta,
|
|
&missing_indices,
|
|
capacity_infos,
|
|
uuid::Uuid::new_v4(),
|
|
generation,
|
|
now,
|
|
model_version,
|
|
)?;
|
|
for idx in missing_indices {
|
|
if let Some(reservation) = meta
|
|
.pools
|
|
.get_mut(idx)
|
|
.and_then(|pool| pool.decommission.as_mut())
|
|
.and_then(|info| info.capacity_reservation.as_mut())
|
|
{
|
|
reservation.recovered_at = Some(now);
|
|
}
|
|
}
|
|
Ok(active_indices)
|
|
}
|
|
|
|
fn signed_capacity_difference(observed: usize, predicted: usize) -> i64 {
|
|
if observed >= predicted {
|
|
i64::try_from(observed.saturating_sub(predicted)).unwrap_or(i64::MAX)
|
|
} else {
|
|
-i64::try_from(predicted.saturating_sub(observed)).unwrap_or(i64::MAX)
|
|
}
|
|
}
|
|
|
|
fn active_decommission_target_reservations(meta: &PoolMeta) -> HashMap<usize, usize> {
|
|
let mut target_reservations = HashMap::new();
|
|
for reservation in meta
|
|
.pools
|
|
.iter()
|
|
.filter_map(|pool| pool.decommission.as_ref()?.capacity_reservation.as_ref())
|
|
.filter(|reservation| reservation.active())
|
|
{
|
|
for target in &reservation.targets {
|
|
let reserved = target_reservations.entry(target.pool_index).or_insert(0usize);
|
|
*reserved = reserved.saturating_add(target.remaining_reserved_physical_bytes(reservation.temporary_copies));
|
|
}
|
|
}
|
|
target_reservations
|
|
}
|
|
|
|
fn observe_decommission_capacity_reservation(
|
|
info: &mut PoolDecommissionInfo,
|
|
_capacity_infos: &[DecommissionPoolCapacityInfo],
|
|
_active_target_reservations: &HashMap<usize, usize>,
|
|
) {
|
|
let Some(reservation) = info.capacity_reservation.as_mut() else {
|
|
return;
|
|
};
|
|
reservation.prediction_error_bytes =
|
|
signed_capacity_difference(reservation.observed_target_physical_bytes, reservation.consumed_target_physical_bytes);
|
|
metrics::gauge!(
|
|
METRIC_DECOMMISSION_CAPACITY_PREDICTION_ERROR_BYTES,
|
|
"pool_index" => reservation.source_pool_index.to_string()
|
|
)
|
|
.set(reservation.prediction_error_bytes as f64);
|
|
metrics::histogram!(
|
|
METRIC_DECOMMISSION_CAPACITY_PREDICTION_ABSOLUTE_ERROR_BYTES,
|
|
"pool_index" => reservation.source_pool_index.to_string()
|
|
)
|
|
.record(reservation.prediction_error_bytes.unsigned_abs() as f64);
|
|
}
|
|
|
|
fn ensure_decommission_capacity_reservations_available(
|
|
meta: &PoolMeta,
|
|
capacity_infos: &[DecommissionPoolCapacityInfo],
|
|
phase: &'static str,
|
|
) -> Result<()> {
|
|
let mut required_by_target = HashMap::<usize, usize>::new();
|
|
let mut inflight_by_target = HashMap::<usize, usize>::new();
|
|
let mut pending_by_target = HashMap::<usize, usize>::new();
|
|
for source_index in active_decommission_source_indices(meta) {
|
|
let info = meta.pools[source_index]
|
|
.decommission
|
|
.as_ref()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("check decommission capacity reservation"))?;
|
|
let reservation = info.capacity_reservation.as_ref().ok_or_else(|| {
|
|
decommission_capacity_blocked_error(format!(
|
|
"active decommission source pool {source_index} has no durable capacity reservation"
|
|
))
|
|
})?;
|
|
if !reservation.active() {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"active decommission source pool {source_index} has a released capacity reservation"
|
|
)));
|
|
}
|
|
metrics::gauge!(
|
|
METRIC_DECOMMISSION_CAPACITY_RESERVED_BYTES,
|
|
"pool_index" => source_index.to_string()
|
|
)
|
|
.set(reservation.remaining_peak_physical_bytes() as f64);
|
|
for target in &reservation.targets {
|
|
let temporary_budget = target.remaining_reserved_physical_bytes(reservation.temporary_copies)
|
|
/ 1usize.saturating_add(reservation.temporary_copies);
|
|
if target.inflight_physical_bytes > temporary_budget {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {source_index} target pool {} has {} inflight physical bytes, exceeding its {temporary_budget}-byte temporary-copy reservation during {phase}",
|
|
target.pool_index, target.inflight_physical_bytes
|
|
)));
|
|
}
|
|
if target.pending_physical_bytes > temporary_budget {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {source_index} target pool {} has {} pending physical bytes, exceeding its {temporary_budget}-byte committed-copy reservation during {phase}",
|
|
target.pool_index, target.pending_physical_bytes
|
|
)));
|
|
}
|
|
let required = required_by_target.entry(target.pool_index).or_default();
|
|
*required = required.saturating_add(target.remaining_reserved_physical_bytes(reservation.temporary_copies));
|
|
let inflight = inflight_by_target.entry(target.pool_index).or_default();
|
|
*inflight = inflight.saturating_add(target.inflight_physical_bytes);
|
|
let pending = pending_by_target.entry(target.pool_index).or_default();
|
|
*pending = pending.saturating_add(target.pending_physical_bytes);
|
|
}
|
|
}
|
|
for (target_pool_index, required) in required_by_target {
|
|
let available = capacity_infos
|
|
.iter()
|
|
.find(|capacity| capacity.pool_index == target_pool_index)
|
|
.map(|capacity| capacity.physical_free)
|
|
.ok_or_else(|| {
|
|
decommission_capacity_blocked_error(format!(
|
|
"capacity snapshot is missing target pool {target_pool_index} during {phase}"
|
|
))
|
|
})?
|
|
.saturating_add(inflight_by_target.get(&target_pool_index).copied().unwrap_or_default())
|
|
.saturating_add(pending_by_target.get(&target_pool_index).copied().unwrap_or_default());
|
|
if available >= required {
|
|
continue;
|
|
}
|
|
metrics::counter!(METRIC_DECOMMISSION_CAPACITY_CONFLICTS_TOTAL, "phase" => phase).increment(1);
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"target pool {target_pool_index} reservation requires {required} physical bytes, but only {available} bytes remain during {phase}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_external_decommission_target_admission(meta: &PoolMeta, target_pool_index: usize, phase: &'static str) -> Result<()> {
|
|
if active_decommission_source_indices(meta).into_iter().any(|source_pool_index| {
|
|
meta.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.is_none_or(|reservation| !reservation.active())
|
|
}) {
|
|
metrics::counter!(METRIC_DECOMMISSION_CAPACITY_CONFLICTS_TOTAL, "phase" => phase).increment(1);
|
|
return Err(Error::SlowDown);
|
|
}
|
|
let reserved = active_decommission_target_reservations(meta)
|
|
.get(&target_pool_index)
|
|
.copied()
|
|
.unwrap_or_default();
|
|
if reserved == 0 {
|
|
return Ok(());
|
|
}
|
|
metrics::counter!(METRIC_DECOMMISSION_CAPACITY_CONFLICTS_TOTAL, "phase" => phase).increment(1);
|
|
Err(Error::SlowDown)
|
|
}
|
|
|
|
fn ensure_decommission_target_owner_admission(
|
|
meta: &PoolMeta,
|
|
owner: DecommissionCapacityOwner,
|
|
target_pool_index: usize,
|
|
target_physical_bytes: usize,
|
|
now: OffsetDateTime,
|
|
) -> Result<()> {
|
|
let reservation = meta
|
|
.pools
|
|
.get(owner.source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| reservation.admits_owner(owner, now))
|
|
.ok_or_else(|| Error::SlowDown)?;
|
|
let target = reservation
|
|
.targets
|
|
.iter()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.ok_or_else(|| Error::SlowDown)?;
|
|
let required_peak = target_physical_bytes.saturating_mul(1usize.saturating_add(reservation.temporary_copies));
|
|
let remaining = target.remaining_reserved_physical_bytes(reservation.temporary_copies);
|
|
if required_peak <= remaining {
|
|
return Ok(());
|
|
}
|
|
Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {} target pool {target_pool_index} operation requires {required_peak} physical bytes, but only {remaining} reserved bytes remain",
|
|
owner.source_pool_index
|
|
)))
|
|
}
|
|
|
|
fn reserve_decommission_target_pending(
|
|
meta: &mut PoolMeta,
|
|
source_pool_index: usize,
|
|
target_pool_index: usize,
|
|
requested_physical_bytes: usize,
|
|
mutation_id: uuid::Uuid,
|
|
now: OffsetDateTime,
|
|
) -> Result<usize> {
|
|
if requested_physical_bytes == 0 {
|
|
return Ok(0);
|
|
}
|
|
let pool_count = meta.pools.len();
|
|
let pool = meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
let info = pool
|
|
.decommission
|
|
.as_mut()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("reserve target capacity mutation"))?;
|
|
let reservation = info
|
|
.capacity_reservation
|
|
.as_mut()
|
|
.filter(|reservation| reservation.active())
|
|
.ok_or_else(|| decommission_capacity_blocked_error("active reservation disappeared before target write"))?;
|
|
let target = reservation
|
|
.targets
|
|
.iter_mut()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("target allocation disappeared before target write"))?;
|
|
if target.pending_physical_bytes == 0 {
|
|
target.pending_mutation_id = None;
|
|
}
|
|
if target.pending_physical_bytes > 0 {
|
|
match target.pending_mutation_id {
|
|
Some(pending_mutation_id) if pending_mutation_id == mutation_id => {
|
|
if requested_physical_bytes <= target.pending_physical_bytes {
|
|
return Ok(0);
|
|
}
|
|
let budget = target.remaining_reserved_physical_bytes(reservation.temporary_copies)
|
|
/ 1usize.saturating_add(reservation.temporary_copies);
|
|
if requested_physical_bytes > budget {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {source_pool_index} target pool {target_pool_index} mutation intent size changed during retry"
|
|
)));
|
|
}
|
|
let additional = requested_physical_bytes.saturating_sub(target.pending_physical_bytes);
|
|
target.pending_physical_bytes = requested_physical_bytes;
|
|
reservation.pending_target_physical_bytes = reservation.pending_target_physical_bytes.saturating_add(additional);
|
|
renew_decommission_capacity_reservation(reservation, now, true);
|
|
pool.last_update = now;
|
|
return Ok(additional);
|
|
}
|
|
pending_mutation_id => {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {source_pool_index} target pool {target_pool_index} has an unresolved target capacity intent {pending_mutation_id:?} while mutation {mutation_id} is waiting"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
let budget = target.remaining_reserved_physical_bytes(reservation.temporary_copies)
|
|
/ 1usize.saturating_add(reservation.temporary_copies);
|
|
if requested_physical_bytes > budget {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {source_pool_index} target pool {target_pool_index} mutation requires {requested_physical_bytes} physical bytes, but only {budget} committed-copy bytes remain"
|
|
)));
|
|
}
|
|
let additional = requested_physical_bytes;
|
|
target.pending_mutation_id = Some(mutation_id);
|
|
target.pending_physical_bytes = target.pending_physical_bytes.saturating_add(additional);
|
|
reservation.pending_target_physical_bytes = reservation.pending_target_physical_bytes.saturating_add(additional);
|
|
renew_decommission_capacity_reservation(reservation, now, true);
|
|
pool.last_update = now;
|
|
Ok(additional)
|
|
}
|
|
|
|
fn resolve_decommission_target_pending(
|
|
meta: &mut PoolMeta,
|
|
source_pool_index: usize,
|
|
target_pool_index: usize,
|
|
resolved_physical_bytes: usize,
|
|
mutation_id: uuid::Uuid,
|
|
) -> Result<()> {
|
|
if resolved_physical_bytes == 0 {
|
|
return Ok(());
|
|
}
|
|
let pool_count = meta.pools.len();
|
|
let pool = meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
let reservation = pool
|
|
.decommission
|
|
.as_mut()
|
|
.and_then(|info| info.capacity_reservation.as_mut())
|
|
.filter(|reservation| reservation.active())
|
|
.ok_or_else(|| decommission_capacity_blocked_error("active reservation disappeared after target write"))?;
|
|
let target = reservation
|
|
.targets
|
|
.iter_mut()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("target allocation disappeared after target write"))?;
|
|
if target.pending_physical_bytes > 0 && target.pending_mutation_id != Some(mutation_id) {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {source_pool_index} target pool {target_pool_index} pending capacity intent belongs to another mutation"
|
|
)));
|
|
}
|
|
let resolved = target.pending_physical_bytes.min(resolved_physical_bytes);
|
|
target.pending_physical_bytes = target.pending_physical_bytes.saturating_sub(resolved);
|
|
reservation.pending_target_physical_bytes = reservation.pending_target_physical_bytes.saturating_sub(resolved);
|
|
if target.pending_physical_bytes == 0 {
|
|
target.pending_mutation_id = None;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn release_decommission_target_temporary_mutation(
|
|
target: &mut DecommissionCapacityTarget,
|
|
mutation_id: uuid::Uuid,
|
|
maximum_physical_bytes: usize,
|
|
) -> (usize, bool) {
|
|
let Some(index) = target
|
|
.temporary_mutations
|
|
.iter()
|
|
.position(|mutation| mutation.mutation_id == mutation_id)
|
|
else {
|
|
return (0, false);
|
|
};
|
|
let released = target.temporary_mutations[index].physical_bytes.min(maximum_physical_bytes);
|
|
target.temporary_mutations[index].physical_bytes = target.temporary_mutations[index].physical_bytes.saturating_sub(released);
|
|
let removed = target.temporary_mutations[index].physical_bytes == 0;
|
|
if removed {
|
|
target.temporary_mutations.remove(index);
|
|
}
|
|
target.inflight_physical_bytes = target.inflight_physical_bytes.saturating_sub(released);
|
|
(released, released > 0 || removed)
|
|
}
|
|
|
|
fn settle_decommission_target_non_growing_replacement(
|
|
meta: &mut PoolMeta,
|
|
source_pool_index: usize,
|
|
target_pool_index: usize,
|
|
mutation_id: uuid::Uuid,
|
|
now: OffsetDateTime,
|
|
) -> Result<bool> {
|
|
let pool_count = meta.pools.len();
|
|
let pool = meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
let info = pool
|
|
.decommission
|
|
.as_mut()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("settle non-growing target replacement"))?;
|
|
let reservation = info
|
|
.capacity_reservation
|
|
.as_mut()
|
|
.filter(|reservation| reservation.active())
|
|
.ok_or_else(|| decommission_capacity_blocked_error("active reservation disappeared during target replacement"))?;
|
|
let target = reservation
|
|
.targets
|
|
.iter_mut()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("target allocation disappeared during target replacement"))?;
|
|
let (released, changed) = release_decommission_target_temporary_mutation(target, mutation_id, usize::MAX);
|
|
reservation.inflight_target_physical_bytes = reservation.inflight_target_physical_bytes.saturating_sub(released);
|
|
if changed {
|
|
renew_decommission_capacity_reservation(reservation, now, true);
|
|
pool.last_update = now;
|
|
}
|
|
Ok(changed)
|
|
}
|
|
|
|
struct DecommissionTargetConsumption {
|
|
committed_data_bytes: usize,
|
|
target_physical_bytes: usize,
|
|
observed_physical_bytes: usize,
|
|
}
|
|
|
|
fn record_decommission_target_consumption(
|
|
meta: &mut PoolMeta,
|
|
source_pool_index: usize,
|
|
target_pool_index: usize,
|
|
consumption: DecommissionTargetConsumption,
|
|
mutation_id: uuid::Uuid,
|
|
now: OffsetDateTime,
|
|
) -> Result<()> {
|
|
let DecommissionTargetConsumption {
|
|
committed_data_bytes,
|
|
target_physical_bytes,
|
|
observed_physical_bytes,
|
|
} = consumption;
|
|
let pool_count = meta.pools.len();
|
|
let pool = meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
let info = pool
|
|
.decommission
|
|
.as_mut()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("record target capacity consumption"))?;
|
|
let reservation = info
|
|
.capacity_reservation
|
|
.as_mut()
|
|
.filter(|reservation| reservation.active())
|
|
.ok_or_else(|| decommission_capacity_blocked_error("active reservation disappeared during target write"))?;
|
|
let target = reservation
|
|
.targets
|
|
.iter_mut()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("target allocation disappeared during target write"))?;
|
|
let remaining_target_bytes = target.remaining_reserved_physical_bytes(reservation.temporary_copies)
|
|
/ 1usize.saturating_add(reservation.temporary_copies);
|
|
let remaining_total_bytes = reservation
|
|
.predicted_physical_bytes
|
|
.saturating_sub(reservation.consumed_target_physical_bytes);
|
|
let consumed = target_physical_bytes.min(remaining_target_bytes).min(remaining_total_bytes);
|
|
target.consumed_physical_bytes = target.consumed_physical_bytes.saturating_add(consumed);
|
|
target.observed_physical_bytes = target.observed_physical_bytes.saturating_add(observed_physical_bytes);
|
|
let has_scoped_temporary_mutations = !target.temporary_mutations.is_empty();
|
|
let (released_inflight, _) = release_decommission_target_temporary_mutation(target, mutation_id, usize::MAX);
|
|
let released_inflight = if released_inflight == 0 && !has_scoped_temporary_mutations {
|
|
let released = target.inflight_physical_bytes.min(consumed);
|
|
target.inflight_physical_bytes = target.inflight_physical_bytes.saturating_sub(released);
|
|
released
|
|
} else {
|
|
released_inflight
|
|
};
|
|
reservation.consumed_target_physical_bytes = reservation.consumed_target_physical_bytes.saturating_add(consumed);
|
|
let committed = committed_data_bytes.min(
|
|
reservation
|
|
.source_data_equivalent_bytes
|
|
.saturating_sub(reservation.committed_data_bytes),
|
|
);
|
|
reservation.committed_data_bytes = reservation.committed_data_bytes.saturating_add(committed);
|
|
reservation.observed_target_physical_bytes = reservation
|
|
.observed_target_physical_bytes
|
|
.saturating_add(observed_physical_bytes);
|
|
reservation.inflight_target_physical_bytes = reservation.inflight_target_physical_bytes.saturating_sub(released_inflight);
|
|
reservation.prediction_error_bytes =
|
|
signed_capacity_difference(reservation.observed_target_physical_bytes, reservation.consumed_target_physical_bytes);
|
|
renew_decommission_capacity_reservation(reservation, now, true);
|
|
info.capacity_blocked_reason = None;
|
|
pool.last_update = now;
|
|
Ok(())
|
|
}
|
|
|
|
fn record_decommission_target_inflight(
|
|
meta: &mut PoolMeta,
|
|
source_pool_index: usize,
|
|
target_pool_index: usize,
|
|
observed_physical_bytes: usize,
|
|
mutation_id: uuid::Uuid,
|
|
now: OffsetDateTime,
|
|
) -> Result<bool> {
|
|
let pool_count = meta.pools.len();
|
|
let pool = meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
let info = pool
|
|
.decommission
|
|
.as_mut()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("record target capacity inflight bytes"))?;
|
|
let reservation = info
|
|
.capacity_reservation
|
|
.as_mut()
|
|
.filter(|reservation| reservation.active())
|
|
.ok_or_else(|| decommission_capacity_blocked_error("active reservation disappeared during target write"))?;
|
|
let target = reservation
|
|
.targets
|
|
.iter_mut()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("target allocation disappeared during target write"))?;
|
|
let ledger_changed = if let Some(mutation) = target
|
|
.temporary_mutations
|
|
.iter_mut()
|
|
.find(|mutation| mutation.mutation_id == mutation_id)
|
|
{
|
|
mutation.physical_bytes = mutation.physical_bytes.saturating_add(observed_physical_bytes);
|
|
observed_physical_bytes > 0
|
|
} else if observed_physical_bytes > 0 || reservation.model_version == DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION {
|
|
// A v2 zero-byte record is a durable discovery marker. It keeps
|
|
// restart cleanup scoped to mutations that actually staged an MPU
|
|
// without charging capacity when statfs observed no physical delta.
|
|
target.temporary_mutations.push(DecommissionCapacityTemporaryMutation {
|
|
mutation_id,
|
|
physical_bytes: observed_physical_bytes,
|
|
});
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
target.observed_physical_bytes = target.observed_physical_bytes.saturating_add(observed_physical_bytes);
|
|
target.inflight_physical_bytes = target.inflight_physical_bytes.saturating_add(observed_physical_bytes);
|
|
reservation.observed_target_physical_bytes = reservation
|
|
.observed_target_physical_bytes
|
|
.saturating_add(observed_physical_bytes);
|
|
reservation.inflight_target_physical_bytes = reservation
|
|
.inflight_target_physical_bytes
|
|
.saturating_add(observed_physical_bytes);
|
|
reservation.prediction_error_bytes =
|
|
signed_capacity_difference(reservation.observed_target_physical_bytes, reservation.consumed_target_physical_bytes);
|
|
renew_decommission_capacity_reservation(reservation, now, true);
|
|
pool.last_update = now;
|
|
Ok(ledger_changed)
|
|
}
|
|
|
|
fn record_decommission_target_observation(
|
|
meta: &mut PoolMeta,
|
|
source_pool_index: usize,
|
|
target_pool_index: usize,
|
|
observed_physical_bytes: usize,
|
|
now: OffsetDateTime,
|
|
) -> Result<()> {
|
|
let pool_count = meta.pools.len();
|
|
let pool = meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
let info = pool
|
|
.decommission
|
|
.as_mut()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("record target capacity observation"))?;
|
|
let reservation = info
|
|
.capacity_reservation
|
|
.as_mut()
|
|
.filter(|reservation| reservation.active())
|
|
.ok_or_else(|| decommission_capacity_blocked_error("active reservation disappeared during target write"))?;
|
|
let target = reservation
|
|
.targets
|
|
.iter_mut()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("target allocation disappeared during target write"))?;
|
|
target.observed_physical_bytes = target.observed_physical_bytes.saturating_add(observed_physical_bytes);
|
|
reservation.observed_target_physical_bytes = reservation
|
|
.observed_target_physical_bytes
|
|
.saturating_add(observed_physical_bytes);
|
|
reservation.prediction_error_bytes =
|
|
signed_capacity_difference(reservation.observed_target_physical_bytes, reservation.consumed_target_physical_bytes);
|
|
renew_decommission_capacity_reservation(reservation, now, true);
|
|
pool.last_update = now;
|
|
Ok(())
|
|
}
|
|
|
|
fn release_decommission_target_inflight(
|
|
meta: &mut PoolMeta,
|
|
source_pool_index: usize,
|
|
target_pool_index: usize,
|
|
released_physical_bytes: usize,
|
|
mutation_id: uuid::Uuid,
|
|
proof: DecommissionCapacityReleaseProof,
|
|
now: OffsetDateTime,
|
|
) -> Result<bool> {
|
|
let DecommissionCapacityReleaseProof {
|
|
confirmed_absent,
|
|
clear_pending,
|
|
} = proof;
|
|
let pool_count = meta.pools.len();
|
|
let pool = meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
let info = pool
|
|
.decommission
|
|
.as_mut()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("release target capacity inflight bytes"))?;
|
|
let reservation = info
|
|
.capacity_reservation
|
|
.as_mut()
|
|
.filter(|reservation| reservation.active())
|
|
.ok_or_else(|| decommission_capacity_blocked_error("active reservation disappeared during target cleanup"))?;
|
|
let model_version = reservation.model_version;
|
|
let target = reservation
|
|
.targets
|
|
.iter_mut()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("target allocation disappeared during target cleanup"))?;
|
|
let has_temporary_mutations = !target.temporary_mutations.is_empty();
|
|
let pending_belongs_to_mutation = target.pending_mutation_id == Some(mutation_id);
|
|
let zero_ledger_cleanup = confirmed_absent
|
|
&& model_version == DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION
|
|
&& !has_temporary_mutations
|
|
&& target.pending_mutation_id.is_none()
|
|
&& target.pending_physical_bytes == 0;
|
|
let (released, temporary_mutation_changed) = release_decommission_target_temporary_mutation(
|
|
target,
|
|
mutation_id,
|
|
if confirmed_absent {
|
|
usize::MAX
|
|
} else {
|
|
released_physical_bytes
|
|
},
|
|
);
|
|
let released = if released == 0 && !has_temporary_mutations && model_version == DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION {
|
|
let released = target.inflight_physical_bytes.min(released_physical_bytes);
|
|
target.inflight_physical_bytes = target.inflight_physical_bytes.saturating_sub(released);
|
|
released
|
|
} else {
|
|
released
|
|
};
|
|
let pending_matches = clear_pending && confirmed_absent && pending_belongs_to_mutation;
|
|
let published_pending_delayed_release = confirmed_absent
|
|
&& model_version == DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION
|
|
&& !clear_pending
|
|
&& !has_temporary_mutations
|
|
&& pending_belongs_to_mutation;
|
|
if released == 0
|
|
&& released_physical_bytes > 0
|
|
&& !zero_ledger_cleanup
|
|
&& !published_pending_delayed_release
|
|
&& (has_temporary_mutations || !pending_matches)
|
|
{
|
|
return Err(decommission_capacity_blocked_error(
|
|
"temporary target cleanup released bytes that cannot be attributed to its mutation",
|
|
));
|
|
}
|
|
reservation.inflight_target_physical_bytes = reservation.inflight_target_physical_bytes.saturating_sub(released);
|
|
let cleared_pending = if pending_matches {
|
|
let cleared = target.pending_physical_bytes;
|
|
target.pending_physical_bytes = 0;
|
|
target.pending_mutation_id = None;
|
|
reservation.pending_target_physical_bytes = reservation.pending_target_physical_bytes.saturating_sub(cleared);
|
|
cleared
|
|
} else {
|
|
0
|
|
};
|
|
let changed = released > 0 || temporary_mutation_changed || cleared_pending > 0;
|
|
if changed {
|
|
renew_decommission_capacity_reservation(reservation, now, true);
|
|
pool.last_update = now;
|
|
}
|
|
Ok(changed)
|
|
}
|
|
|
|
fn ensure_valid_decommission_pool_index(pool_count: usize, idx: usize) -> Result<()> {
|
|
if idx >= pool_count {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn get_by_index<'a, T>(items: &'a [T], idx: usize, operation: &'static str) -> Result<&'a T> {
|
|
items.get(idx).ok_or_else(|| {
|
|
Error::other(format!(
|
|
"failed to {operation}: invalid decommission pool index {idx} for {pool_count} pools",
|
|
pool_count = items.len()
|
|
))
|
|
})
|
|
}
|
|
|
|
fn decommission_metadata_not_initialized_error(operation: &str) -> Error {
|
|
Error::other(format!("failed to {operation}: decommission metadata not initialized"))
|
|
}
|
|
|
|
fn resolve_decommission_bucket_state(meta: &PoolMeta, idx: usize, bucket: &DecomBucketInfo) -> Result<bool> {
|
|
let pool_count = meta.pools.len();
|
|
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
|
|
|
let Some(pool) = meta.pools.get(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_ref() else {
|
|
return Err(decommission_metadata_not_initialized_error("resolve decommission bucket state"));
|
|
};
|
|
|
|
Ok(info.is_bucket_decommissioned(&bucket.to_string()))
|
|
}
|
|
|
|
fn mark_decommission_bucket_done(meta: &mut PoolMeta, idx: usize, bucket: &DecomBucketInfo) -> Result<bool> {
|
|
let pool_count = meta.pools.len();
|
|
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
|
|
|
let Some(pool) = meta.pools.get_mut(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_mut() else {
|
|
return Err(decommission_metadata_not_initialized_error("mark decommission bucket done"));
|
|
};
|
|
|
|
Ok(info.bucket_pop(&bucket.to_string()))
|
|
}
|
|
|
|
fn count_decommission_item(meta: &mut PoolMeta, idx: usize, size: usize, failed: bool) -> Result<()> {
|
|
let pool_count = meta.pools.len();
|
|
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
|
|
|
let Some(pool) = meta.pools.get_mut(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_mut() else {
|
|
return Err(decommission_metadata_not_initialized_error("count decommission item"));
|
|
};
|
|
|
|
if failed {
|
|
info.items_decommission_failed += 1;
|
|
info.bytes_failed += size;
|
|
} else {
|
|
info.items_decommissioned += 1;
|
|
info.bytes_done += size;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_decommission_generation(meta: &PoolMeta, idx: usize, generation: OffsetDateTime) -> Result<()> {
|
|
let Some(pool) = meta.pools.get(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(meta.pools.len(), idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_ref() else {
|
|
return Err(decommission_metadata_not_initialized_error("check decommission generation"));
|
|
};
|
|
|
|
if info.start_time == Some(generation) && !info.queued && is_decommission_active(info.complete, info.failed, info.canceled) {
|
|
Ok(())
|
|
} else {
|
|
Err(Error::OperationCanceled)
|
|
}
|
|
}
|
|
|
|
fn record_decommission_unresolved_entry(
|
|
meta: &mut PoolMeta,
|
|
idx: usize,
|
|
generation: OffsetDateTime,
|
|
entry: DecommissionUnresolvedEntry,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> Result<bool> {
|
|
ensure_decommission_generation(meta, idx, generation)?;
|
|
if entry.pool_index != idx || entry.source_generation != generation {
|
|
return Err(Error::other("decommission unresolved entry does not match the active pool generation"));
|
|
}
|
|
|
|
let pool_count = meta.pools.len();
|
|
let Some(pool) = meta.pools.get(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_ref() else {
|
|
return Err(decommission_metadata_not_initialized_error("record decommission unresolved entry"));
|
|
};
|
|
let existing_index = info.unresolved_entries.iter().position(|existing| {
|
|
existing.bucket == entry.bucket
|
|
&& existing.object == entry.object
|
|
&& existing.pool_index == entry.pool_index
|
|
&& existing.set_index == entry.set_index
|
|
&& existing.source_generation == entry.source_generation
|
|
});
|
|
if existing_index.is_some_and(|index| info.unresolved_entries[index] == entry) {
|
|
return Ok(false);
|
|
}
|
|
|
|
let last_update = meta.next_scanner_data_movement_update(now, rebalance_meta);
|
|
let Some(pool) = meta.pools.get_mut(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_mut() else {
|
|
return Err(decommission_metadata_not_initialized_error("record decommission unresolved entry"));
|
|
};
|
|
if let Some(index) = existing_index {
|
|
info.unresolved_entries[index] = entry;
|
|
} else {
|
|
info.unresolved_entries.push(entry);
|
|
}
|
|
pool.last_update = last_update;
|
|
Ok(true)
|
|
}
|
|
|
|
type DecommissionUnresolvedEntryIdentity = (usize, String, String);
|
|
|
|
fn decommission_unresolved_entry_identity(entry: &DecommissionUnresolvedEntry) -> DecommissionUnresolvedEntryIdentity {
|
|
(entry.set_index, entry.bucket.clone(), entry.object.clone())
|
|
}
|
|
|
|
fn reconcile_decommission_unresolved_entries_for_completion(
|
|
meta: &mut PoolMeta,
|
|
idx: usize,
|
|
verified_generation: Option<OffsetDateTime>,
|
|
verified_entries: Option<&[DecommissionUnresolvedEntry]>,
|
|
) -> Result<()> {
|
|
let pool_count = meta.pools.len();
|
|
let Some(pool) = meta.pools.get(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_ref() else {
|
|
return Err(decommission_metadata_not_initialized_error("reconcile decommission unresolved entries"));
|
|
};
|
|
if info.unresolved_entries.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(generation) = verified_generation else {
|
|
return Err(Error::other(format!(
|
|
"failed to complete decommission for pool {idx}: {} unresolved listing entries remain",
|
|
info.unresolved_entries.len()
|
|
)));
|
|
};
|
|
ensure_decommission_generation(meta, idx, generation)?;
|
|
if info
|
|
.unresolved_entries
|
|
.iter()
|
|
.any(|entry| entry.source_generation != generation)
|
|
{
|
|
return Err(Error::other(format!(
|
|
"failed to complete decommission for pool {idx}: unresolved listing ledger contains a different generation"
|
|
)));
|
|
}
|
|
|
|
let verified_entries = verified_entries.unwrap_or_default();
|
|
let unverified_count = info
|
|
.unresolved_entries
|
|
.iter()
|
|
.filter(|entry| !verified_entries.contains(entry))
|
|
.count();
|
|
if unverified_count > 0 {
|
|
return Err(Error::other(format!(
|
|
"failed to complete decommission for pool {idx}: {unverified_count} unresolved listing entries were not individually verified"
|
|
)));
|
|
}
|
|
|
|
let Some(info) = meta.pools.get_mut(idx).and_then(|pool| pool.decommission.as_mut()) else {
|
|
return Err(decommission_metadata_not_initialized_error("reconcile decommission unresolved entries"));
|
|
};
|
|
info.unresolved_entries.clear();
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn run_decommission_side_effect<T, E, F, Fut>(
|
|
rx: &CancellationToken,
|
|
operation_gate: &Arc<tokio::sync::RwLock<()>>,
|
|
operation: F,
|
|
) -> std::result::Result<T, E>
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: std::future::Future<Output = std::result::Result<T, E>>,
|
|
E: From<Error>,
|
|
{
|
|
let _operation_guard = tokio::select! {
|
|
biased;
|
|
_ = rx.cancelled() => return Err(Error::OperationCanceled.into()),
|
|
guard = operation_gate.read() => guard,
|
|
};
|
|
|
|
if rx.is_cancelled() {
|
|
return Err(Error::OperationCanceled.into());
|
|
}
|
|
|
|
let result = operation().await;
|
|
if rx.is_cancelled() {
|
|
return Err(Error::OperationCanceled.into());
|
|
}
|
|
result
|
|
}
|
|
|
|
fn track_decommission_current_object_stage(
|
|
meta: &mut PoolMeta,
|
|
idx: usize,
|
|
bucket: &str,
|
|
object: &str,
|
|
stage: &str,
|
|
) -> Result<()> {
|
|
let pool_count = meta.pools.len();
|
|
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
|
|
|
let Some(pool) = meta.pools.get_mut(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_mut() else {
|
|
return Err(decommission_metadata_not_initialized_error("track decommission current object"));
|
|
};
|
|
|
|
info.object = object.to_string();
|
|
info.bucket = bucket.to_string();
|
|
info.stage = stage.to_string();
|
|
Ok(())
|
|
}
|
|
|
|
fn track_decommission_current_object(meta: &mut PoolMeta, idx: usize, bucket: &str, object: &str) -> Result<()> {
|
|
track_decommission_current_object_stage(meta, idx, bucket, object, "")
|
|
}
|
|
|
|
fn resolve_decommission_update_after_result(result: Result<bool>) -> Result<bool> {
|
|
result.map_err(|err| Error::other(format!("decommission metadata update failed: {err}")))
|
|
}
|
|
|
|
fn resolve_decommission_progress_save_result(result: Result<()>) -> Option<Error> {
|
|
result
|
|
.err()
|
|
.map(|err| Error::other(format!("decommission progress save failed: {err}")))
|
|
}
|
|
|
|
fn resolve_decommission_preflight_heal_result<T>(bucket: &str, result: Result<T>) -> Result<T> {
|
|
result.map_err(|err| Error::other(format!("decommission preflight heal failed for bucket {bucket}: {err}")))
|
|
}
|
|
|
|
fn resolve_decommission_optional_bucket_config_result<T>(bucket: &str, stage: &str, result: Result<T>) -> Result<Option<T>> {
|
|
match result {
|
|
Ok(config) => Ok(Some(config)),
|
|
Err(Error::ConfigNotFound) => Ok(None),
|
|
Err(err) => Err(Error::other(format!(
|
|
"decommission {stage} config load failed for bucket {bucket}: {err}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn resolve_decommission_entry_cleanup_delete_result<T>(result: Result<T>, bucket: &str, object_name: &str) -> Result<()> {
|
|
match result {
|
|
Ok(_) => Ok(()),
|
|
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => Ok(()),
|
|
Err(err) => Err(Error::other(format!(
|
|
"decommission cleanup_delete_object failed for {bucket}/{object_name}: {err}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn resolve_decommission_entry_reload_result(result: Result<()>, bucket: &str, object_name: &str) -> Result<()> {
|
|
result.map_err(|err| Error::other(format!("decommission reload_pool_meta failed for {bucket}/{object_name}: {err}")))
|
|
}
|
|
|
|
fn resolve_decommission_terminal_mark_result(result: Result<()>, stage: &str, pool_label: &str) -> Result<()> {
|
|
result.map_err(|err| Error::other(format!("decommission terminal mark {stage} failed for pool {pool_label}: {err}")))
|
|
}
|
|
|
|
fn resolve_decommission_terminal_mark_after_error_result(result: Result<()>, idx: usize, primary_err: &Error) -> Result<()> {
|
|
result.map_err(|err| {
|
|
Error::other(format!(
|
|
"decommission terminal mark failed after background error on pool {idx}: {primary_err}; mark error: {err}"
|
|
))
|
|
})
|
|
}
|
|
|
|
fn observe_decommission_terminal_reload_result(result: Result<()>, stage: &str) -> Option<Error> {
|
|
result
|
|
.err()
|
|
.map(|err| Error::other(format!("decommission terminal pool meta reload failed during {stage}: {err}")))
|
|
}
|
|
|
|
fn decommission_item_size<T>(size: T) -> usize
|
|
where
|
|
usize: TryFrom<T>,
|
|
{
|
|
usize::try_from(size).unwrap_or_default()
|
|
}
|
|
|
|
fn with_decommission_entry_context<E: Display>(stage: &str, bucket: &str, object: &str, err: E) -> Error {
|
|
Error::other(format!("decommission entry {stage} failed for bucket {bucket} object {object}: {err}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn load_decommission_entry_versions(entry: &MetaCacheEntry, bucket: &str, stage: &str) -> Result<FileInfoVersions> {
|
|
entry
|
|
.file_info_versions(bucket)
|
|
.map_err(|err| with_decommission_entry_context(stage, bucket, &entry.name, err))
|
|
}
|
|
|
|
fn empty_decommission_entry_versions(bucket: &str, object: &str) -> FileInfoVersions {
|
|
FileInfoVersions {
|
|
volume: bucket.to_string(),
|
|
name: object.to_string(),
|
|
versions: Vec::new(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn resolve_decommission_entry_exact_versions(
|
|
result: Result<Option<FileInfoVersions>>,
|
|
entry: &MetaCacheEntry,
|
|
bucket: &str,
|
|
stage: &str,
|
|
) -> Result<FileInfoVersions> {
|
|
match result {
|
|
Ok(Some(fivs)) => Ok(fivs),
|
|
Ok(None) => Ok(empty_decommission_entry_versions(bucket, &entry.name)),
|
|
Err(err) => Err(with_decommission_entry_context(stage, bucket, &entry.name, err)),
|
|
}
|
|
}
|
|
|
|
async fn load_decommission_entry_exact_versions(
|
|
set: &SetDisks,
|
|
entry: &MetaCacheEntry,
|
|
bucket: &str,
|
|
stage: &str,
|
|
) -> Result<FileInfoVersions> {
|
|
resolve_decommission_entry_exact_versions(set.load_file_info_versions_exact(bucket, &entry.name).await, entry, bucket, stage)
|
|
}
|
|
|
|
fn resolve_decommission_check_after_list_result(list_result: Result<()>, entry_error: Option<Error>) -> Result<()> {
|
|
match list_result {
|
|
Ok(()) => entry_error.map_or(Ok(()), Err),
|
|
Err(list_err) => resolve_decommission_listing_error(Some(list_err), entry_error).map_or(Ok(()), Err),
|
|
}
|
|
}
|
|
|
|
fn resolve_decommission_listing_error(listing_error: Option<Error>, entry_error: Option<Error>) -> Option<Error> {
|
|
match (listing_error, entry_error) {
|
|
(Some(listing_error), Some(entry_error)) if is_err_operation_canceled(&listing_error) => Some(entry_error),
|
|
(Some(listing_error), Some(entry_error)) if is_err_operation_canceled(&entry_error) => Some(listing_error),
|
|
(Some(listing_error), _) => Some(listing_error),
|
|
(None, entry_error) => entry_error,
|
|
}
|
|
}
|
|
|
|
fn decommission_unresolved_listing_error(entry: &DecommissionUnresolvedEntry) -> Error {
|
|
Error::other(format!(
|
|
"decommission listing could not resolve metadata for {bucket}/{object} on pool {pool_index} set {set_index} ({candidate_count} candidate(s), {disk_error_count} disk error(s))",
|
|
bucket = entry.bucket,
|
|
object = entry.object,
|
|
pool_index = entry.pool_index,
|
|
set_index = entry.set_index,
|
|
candidate_count = entry.candidate_count,
|
|
disk_error_count = entry.disk_error_count,
|
|
))
|
|
}
|
|
|
|
// The unresolved-entry payload carries listing context by design; boxing it
|
|
// would churn every caller without changing behavior.
|
|
#[allow(clippy::result_large_err)]
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn resolve_decommission_partial_listing_entry(
|
|
entries: MetaCacheEntries,
|
|
resolver: MetadataResolutionParams,
|
|
bucket: &str,
|
|
prefix: &str,
|
|
disk_error_count: usize,
|
|
pool_index: usize,
|
|
set_index: usize,
|
|
source_generation: OffsetDateTime,
|
|
) -> std::result::Result<MetaCacheEntry, DecommissionUnresolvedEntry> {
|
|
let candidate_count = entries.as_ref().iter().flatten().count();
|
|
if let Some(entry) = entries.resolve(resolver) {
|
|
return Ok(entry);
|
|
}
|
|
|
|
let object = entries
|
|
.as_ref()
|
|
.iter()
|
|
.flatten()
|
|
.map(|entry| entry.name.as_str())
|
|
.next()
|
|
.unwrap_or(prefix)
|
|
.to_string();
|
|
Err(DecommissionUnresolvedEntry {
|
|
bucket: bucket.to_string(),
|
|
object,
|
|
candidate_count,
|
|
disk_error_count,
|
|
pool_index,
|
|
set_index,
|
|
source_generation,
|
|
observed_at: OffsetDateTime::now_utc(),
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
})
|
|
}
|
|
|
|
fn validate_decommission_durable_ilm_copy(
|
|
path: &str,
|
|
source_record: &ValidatedDurableIlmRecord,
|
|
target: &[u8],
|
|
) -> Result<ValidatedDurableIlmRecord> {
|
|
let target_record = validate_durable_ilm_record(path, target).map_err(|err| {
|
|
Error::other(format!(
|
|
"target durable ILM record is invalid at path `{path}` {}: {err}",
|
|
source_record.context()
|
|
))
|
|
})?;
|
|
source_record
|
|
.checkpoint
|
|
.validate_successor(&target_record.checkpoint)
|
|
.map_err(|err| {
|
|
Error::other(format!(
|
|
"target durable ILM record generation mismatch at path `{path}` {}: {err}",
|
|
source_record.context()
|
|
))
|
|
})?;
|
|
Ok(target_record)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct DecommissionDurableIlmReceipt {
|
|
source_path: String,
|
|
namespace: String,
|
|
id_kind: String,
|
|
id: String,
|
|
checkpoint: DurableIlmRecordCheckpoint,
|
|
terminal_checkpoint: Option<DurableIlmRecordCheckpoint>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
fleet_topology_generation: Option<String>,
|
|
}
|
|
|
|
impl DecommissionDurableIlmReceipt {
|
|
fn new(path: &str, record: &ValidatedDurableIlmRecord, fleet_topology_generation: Option<String>) -> Self {
|
|
Self {
|
|
source_path: path.to_string(),
|
|
namespace: record.namespace.to_string(),
|
|
id_kind: record.id_kind.to_string(),
|
|
id: record.id.clone(),
|
|
checkpoint: record.checkpoint.clone(),
|
|
terminal_checkpoint: None,
|
|
fleet_topology_generation,
|
|
}
|
|
}
|
|
|
|
fn context(&self) -> String {
|
|
format!("namespace `{}` {} `{}`", self.namespace, self.id_kind, self.id)
|
|
}
|
|
|
|
fn validate(&self) -> Result<()> {
|
|
let namespace = classify_durable_ilm_record(&self.source_path)?
|
|
.ok_or_else(|| Error::other(format!("receipt source path `{}` is not a durable ILM record", self.source_path)))?;
|
|
if namespace.name != self.namespace {
|
|
return Err(Error::other(format!(
|
|
"receipt namespace `{}` does not match source path `{}`",
|
|
self.namespace, self.source_path
|
|
)));
|
|
}
|
|
if self.id_kind.is_empty() || self.id.is_empty() {
|
|
return Err(Error::other(format!(
|
|
"receipt identity is missing for source path `{}`",
|
|
self.source_path
|
|
)));
|
|
}
|
|
if !is_sha256_checksum(self.checkpoint.content_sha256()) {
|
|
return Err(Error::other(format!(
|
|
"receipt target checksum is invalid for source path `{}` {}",
|
|
self.source_path,
|
|
self.context()
|
|
)));
|
|
}
|
|
if let Some(terminal_checkpoint) = &self.terminal_checkpoint {
|
|
self.checkpoint.validate_successor(terminal_checkpoint).map_err(|err| {
|
|
Error::other(format!(
|
|
"receipt terminal checkpoint is invalid for source path `{}` {}: {err}",
|
|
self.source_path,
|
|
self.context()
|
|
))
|
|
})?;
|
|
}
|
|
if self
|
|
.fleet_topology_generation
|
|
.as_deref()
|
|
.is_some_and(|generation| !is_sha256_checksum(generation))
|
|
{
|
|
return Err(Error::other_with_context(
|
|
"receipt fleet topology generation is invalid",
|
|
format!("source path `{}` {}", self.source_path, self.context()),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn encode(&self) -> Result<Vec<u8>> {
|
|
let mut receipt = self.clone();
|
|
receipt.checkpoint = receipt.checkpoint.compacted()?;
|
|
receipt.terminal_checkpoint = receipt
|
|
.terminal_checkpoint
|
|
.as_ref()
|
|
.map(DurableIlmRecordCheckpoint::compacted)
|
|
.transpose()?;
|
|
receipt.validate()?;
|
|
let receipt_bytes = serde_json::to_vec(&receipt)?;
|
|
let persisted = PersistedDecommissionDurableIlmReceipt {
|
|
schema: DECOMMISSION_DURABLE_ILM_RECEIPT_SCHEMA.to_string(),
|
|
content_sha256: hex_sha256(&receipt_bytes, ToOwned::to_owned),
|
|
receipt,
|
|
};
|
|
let encoded = serde_json::to_vec(&persisted)?;
|
|
if encoded.len() > DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE {
|
|
return Err(Error::other(format!(
|
|
"durable ILM receipt exceeds maximum size for source path `{}` {}",
|
|
self.source_path,
|
|
self.context()
|
|
)));
|
|
}
|
|
Ok(encoded)
|
|
}
|
|
|
|
fn decode(data: &[u8]) -> Result<Self> {
|
|
if data.len() > DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE {
|
|
return Err(Error::other("durable ILM receipt exceeds maximum size"));
|
|
}
|
|
let persisted: PersistedDecommissionDurableIlmReceipt = serde_json::from_slice(data)?;
|
|
if persisted.schema != DECOMMISSION_DURABLE_ILM_RECEIPT_SCHEMA {
|
|
return Err(Error::other(format!("unsupported durable ILM receipt schema `{}`", persisted.schema)));
|
|
}
|
|
if !is_sha256_checksum(&persisted.content_sha256) {
|
|
return Err(Error::other("durable ILM receipt checksum is invalid"));
|
|
}
|
|
let receipt_bytes = serde_json::to_vec(&persisted.receipt)?;
|
|
let actual_checksum = hex_sha256(&receipt_bytes, ToOwned::to_owned);
|
|
if persisted.content_sha256 != actual_checksum {
|
|
return Err(Error::other("durable ILM receipt checksum mismatch"));
|
|
}
|
|
persisted.receipt.validate()?;
|
|
Ok(persisted.receipt)
|
|
}
|
|
}
|
|
|
|
fn merge_decommission_durable_ilm_receipts(
|
|
existing: &DecommissionDurableIlmReceipt,
|
|
incoming: &DecommissionDurableIlmReceipt,
|
|
) -> Result<DecommissionDurableIlmReceipt> {
|
|
if existing.source_path != incoming.source_path
|
|
|| existing.namespace != incoming.namespace
|
|
|| existing.id_kind != incoming.id_kind
|
|
|| existing.id != incoming.id
|
|
{
|
|
return Err(Error::other(format!(
|
|
"durable ILM receipt identity conflict for source path `{}` {}; incoming {}",
|
|
existing.source_path,
|
|
existing.context(),
|
|
incoming.context()
|
|
)));
|
|
}
|
|
|
|
let checkpoint =
|
|
if existing.checkpoint == incoming.checkpoint || incoming.checkpoint.validate_successor(&existing.checkpoint).is_ok() {
|
|
existing.checkpoint.clone()
|
|
} else {
|
|
existing.checkpoint.validate_successor(&incoming.checkpoint).map_err(|err| {
|
|
Error::other(format!(
|
|
"durable ILM receipt checkpoint conflict for source path `{}` {}: {err}",
|
|
existing.source_path,
|
|
existing.context()
|
|
))
|
|
})?;
|
|
incoming.checkpoint.clone()
|
|
};
|
|
let terminal_checkpoint = match (&existing.terminal_checkpoint, &incoming.terminal_checkpoint) {
|
|
(Some(existing_terminal), Some(incoming_terminal)) if existing_terminal == incoming_terminal => {
|
|
Some(existing_terminal.clone())
|
|
}
|
|
(Some(existing_terminal), Some(incoming_terminal)) if incoming_terminal.validate_successor(existing_terminal).is_ok() => {
|
|
Some(existing_terminal.clone())
|
|
}
|
|
(Some(existing_terminal), Some(incoming_terminal)) => {
|
|
existing_terminal.validate_successor(incoming_terminal).map_err(|err| {
|
|
Error::other(format!(
|
|
"durable ILM receipt terminal checkpoint conflict for source path `{}` {}: {err}",
|
|
existing.source_path,
|
|
existing.context()
|
|
))
|
|
})?;
|
|
Some(incoming_terminal.clone())
|
|
}
|
|
(Some(existing_terminal), None) => Some(existing_terminal.clone()),
|
|
(None, Some(incoming_terminal)) => Some(incoming_terminal.clone()),
|
|
(None, None) => None,
|
|
};
|
|
let merged = DecommissionDurableIlmReceipt {
|
|
source_path: existing.source_path.clone(),
|
|
namespace: existing.namespace.clone(),
|
|
id_kind: existing.id_kind.clone(),
|
|
id: existing.id.clone(),
|
|
checkpoint,
|
|
terminal_checkpoint,
|
|
fleet_topology_generation: match (
|
|
existing.fleet_topology_generation.as_ref(),
|
|
incoming.fleet_topology_generation.as_ref(),
|
|
) {
|
|
(Some(existing_generation), Some(incoming_generation)) if existing_generation == incoming_generation => {
|
|
Some(existing_generation.clone())
|
|
}
|
|
(None, None) => None,
|
|
_ => {
|
|
return Err(Error::other_with_context(
|
|
"durable ILM receipt fleet topology conflict",
|
|
format!("source path `{}` {}", existing.source_path, existing.context()),
|
|
));
|
|
}
|
|
},
|
|
};
|
|
merged.validate()?;
|
|
Ok(merged)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedDecommissionDurableIlmReceipt {
|
|
schema: String,
|
|
content_sha256: String,
|
|
receipt: DecommissionDurableIlmReceipt,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct DecommissionDurableIlmManifest {
|
|
schema: String,
|
|
run_token: String,
|
|
receipt_count: u64,
|
|
receipt_paths_sha256: String,
|
|
}
|
|
|
|
impl DecommissionDurableIlmManifest {
|
|
fn new(run_token: &str, receipt_paths: &[String]) -> Result<Self> {
|
|
let manifest = Self {
|
|
schema: DECOMMISSION_DURABLE_ILM_MANIFEST_SCHEMA.to_string(),
|
|
run_token: run_token.to_string(),
|
|
receipt_count: u64::try_from(receipt_paths.len())
|
|
.map_err(|_| Error::other("durable ILM expected manifest receipt count exceeds u64"))?,
|
|
receipt_paths_sha256: decommission_durable_ilm_manifest_paths_sha256(receipt_paths)?,
|
|
};
|
|
manifest.validate(run_token, receipt_paths)?;
|
|
Ok(manifest)
|
|
}
|
|
|
|
fn validate(&self, run_token: &str, receipt_paths: &[String]) -> Result<()> {
|
|
if self.schema != DECOMMISSION_DURABLE_ILM_MANIFEST_SCHEMA {
|
|
return Err(Error::other(format!(
|
|
"unsupported durable ILM expected manifest schema `{}`",
|
|
self.schema
|
|
)));
|
|
}
|
|
if self.run_token != run_token || !is_sha256_checksum(&self.run_token) {
|
|
return Err(Error::other("durable ILM expected manifest run token is invalid"));
|
|
}
|
|
let receipt_count = u64::try_from(receipt_paths.len())
|
|
.map_err(|_| Error::other("durable ILM expected manifest receipt count exceeds u64"))?;
|
|
if self.receipt_count != receipt_count {
|
|
return Err(Error::other(format!(
|
|
"durable ILM expected manifest receipt count mismatch: expected {}, found {receipt_count}",
|
|
self.receipt_count
|
|
)));
|
|
}
|
|
if !is_sha256_checksum(&self.receipt_paths_sha256)
|
|
|| self.receipt_paths_sha256 != decommission_durable_ilm_manifest_paths_sha256(receipt_paths)?
|
|
{
|
|
return Err(Error::other("durable ILM expected manifest receipt paths checksum mismatch"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn encode(&self) -> Result<Vec<u8>> {
|
|
let encoded = serde_json::to_vec(self)?;
|
|
if encoded.len() > DECOMMISSION_DURABLE_ILM_MANIFEST_MAX_SIZE {
|
|
return Err(Error::other("durable ILM expected manifest exceeds maximum size"));
|
|
}
|
|
Ok(encoded)
|
|
}
|
|
|
|
fn decode(data: &[u8], run_token: &str, receipt_paths: &[String]) -> Result<Self> {
|
|
if data.len() > DECOMMISSION_DURABLE_ILM_MANIFEST_MAX_SIZE {
|
|
return Err(Error::other("durable ILM expected manifest exceeds maximum size"));
|
|
}
|
|
let manifest: Self = serde_json::from_slice(data)?;
|
|
manifest.validate(run_token, receipt_paths)?;
|
|
Ok(manifest)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct DecommissionDurableIlmReceiptLocator {
|
|
run_token: String,
|
|
source_path: String,
|
|
id_kind: String,
|
|
id: String,
|
|
}
|
|
|
|
impl DecommissionDurableIlmReceiptLocator {
|
|
fn context(&self) -> String {
|
|
format!("source path `{}` {} `{}`", self.source_path, self.id_kind, self.id)
|
|
}
|
|
}
|
|
|
|
fn decommission_durable_ilm_receipt_run_token(cmd_line: &str, start_time: OffsetDateTime) -> String {
|
|
let identity = format!("{cmd_line}\0{}", start_time.unix_timestamp_nanos());
|
|
hex_sha256(identity.as_bytes(), ToOwned::to_owned)
|
|
}
|
|
|
|
fn decommission_durable_ilm_receipt_run_prefix(run_token: &str) -> String {
|
|
format!("{DECOMMISSION_DURABLE_ILM_RECEIPT_ROOT}/{run_token}/")
|
|
}
|
|
|
|
fn decommission_durable_ilm_receipt_path(run_token: &str, source_path: &str, id_kind: &str, id: &str) -> String {
|
|
format!(
|
|
"{}{}/{}/{}.json",
|
|
decommission_durable_ilm_receipt_run_prefix(run_token),
|
|
source_path,
|
|
id_kind,
|
|
id
|
|
)
|
|
}
|
|
|
|
fn decommission_durable_ilm_manifest_path(run_token: &str) -> String {
|
|
format!("{DECOMMISSION_DURABLE_ILM_MANIFEST_ROOT}/{run_token}.json")
|
|
}
|
|
|
|
fn decommission_durable_ilm_manifest_paths_sha256(receipt_paths: &[String]) -> Result<String> {
|
|
let mut sorted_paths = receipt_paths.iter().map(String::as_str).collect::<Vec<_>>();
|
|
sorted_paths.sort_unstable();
|
|
let encoded = serde_json::to_vec(&sorted_paths)?;
|
|
Ok(hex_sha256(&encoded, ToOwned::to_owned))
|
|
}
|
|
|
|
fn parse_decommission_durable_ilm_receipt_path(path: &str) -> Result<DecommissionDurableIlmReceiptLocator> {
|
|
let prefix = format!("{DECOMMISSION_DURABLE_ILM_RECEIPT_ROOT}/");
|
|
let suffix = path
|
|
.strip_prefix(&prefix)
|
|
.ok_or_else(|| Error::other(format!("durable ILM receipt path `{path}` has the wrong root")))?;
|
|
let (run_token, record_path) = suffix
|
|
.split_once('/')
|
|
.ok_or_else(|| Error::other(format!("durable ILM receipt path `{path}` is missing its record path")))?;
|
|
let mut parts = record_path.rsplitn(3, '/');
|
|
let id = parts
|
|
.next()
|
|
.and_then(|file| file.strip_suffix(".json"))
|
|
.filter(|id| !id.is_empty())
|
|
.ok_or_else(|| Error::other(format!("durable ILM receipt path `{path}` is missing its record id")))?;
|
|
let id_kind = parts
|
|
.next()
|
|
.filter(|id_kind| matches!(*id_kind, "operation_id" | "transaction_id" | "job_id"))
|
|
.ok_or_else(|| Error::other(format!("durable ILM receipt path `{path}` has an invalid id kind")))?;
|
|
let source_path = parts
|
|
.next()
|
|
.filter(|source_path| !source_path.is_empty())
|
|
.ok_or_else(|| Error::other(format!("durable ILM receipt path `{path}` is missing its source path")))?;
|
|
if !is_sha256_checksum(run_token) {
|
|
return Err(Error::other(format!("durable ILM receipt path `{path}` has an invalid run token")));
|
|
}
|
|
match id_kind {
|
|
"operation_id" if !is_sha256_checksum(id) => {
|
|
return Err(Error::other(format!("durable ILM receipt path `{path}` has an invalid operation id")));
|
|
}
|
|
"transaction_id" | "job_id" if uuid::Uuid::parse_str(id).is_err() => {
|
|
return Err(Error::other(format!("durable ILM receipt path `{path}` has an invalid UUID")));
|
|
}
|
|
_ => {}
|
|
}
|
|
classify_durable_ilm_record(source_path)?
|
|
.ok_or_else(|| Error::other(format!("durable ILM receipt path `{path}` does not identify a durable ILM source path")))?;
|
|
Ok(DecommissionDurableIlmReceiptLocator {
|
|
run_token: run_token.to_string(),
|
|
source_path: source_path.to_string(),
|
|
id_kind: id_kind.to_string(),
|
|
id: id.to_string(),
|
|
})
|
|
}
|
|
|
|
fn resolve_decommission_pool_meta_reload_result(result: Result<()>, stage: &str) -> Result<()> {
|
|
result.map_err(|err| Error::other(format!("decommission pool meta reload failed during {stage}: {err}")))
|
|
}
|
|
|
|
fn apply_decommission_status_space_info(mut pool_info: PoolStatus, space_info: PoolSpaceInfo) -> PoolStatus {
|
|
match pool_info.decommission.as_mut() {
|
|
Some(d) => {
|
|
d.total_size = space_info.total;
|
|
d.current_size = space_info.free;
|
|
}
|
|
None => {
|
|
pool_info.decommission = Some(PoolDecommissionInfo {
|
|
total_size: space_info.total,
|
|
current_size: space_info.free,
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
|
|
pool_info
|
|
}
|
|
|
|
fn should_replace_pool_status_for_status_refresh(
|
|
current: Option<&PoolStatus>,
|
|
persisted: &PoolStatus,
|
|
has_active_worker: bool,
|
|
) -> bool {
|
|
let Some(current) = current else {
|
|
return true;
|
|
};
|
|
|
|
!has_active_worker && persisted.last_update > current.last_update
|
|
}
|
|
|
|
fn pool_decommission_movement_snapshot(
|
|
info: Option<&PoolDecommissionInfo>,
|
|
) -> (bool, bool, bool, bool, bool, Option<OffsetDateTime>) {
|
|
info.map(|info| {
|
|
(
|
|
info.has_decommission_state(),
|
|
info.complete,
|
|
info.failed,
|
|
info.canceled,
|
|
info.queued,
|
|
info.start_time,
|
|
)
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub(crate) fn pool_meta_movement_snapshot_changed(before: &PoolMeta, after: &PoolMeta) -> bool {
|
|
before.pools.len() != after.pools.len()
|
|
|| before.pools.iter().zip(after.pools.iter()).any(|(before, after)| {
|
|
pool_decommission_movement_snapshot(before.decommission.as_ref())
|
|
!= pool_decommission_movement_snapshot(after.decommission.as_ref())
|
|
})
|
|
}
|
|
|
|
/// Merges a persisted pool metadata snapshot into `current` monotonically:
|
|
/// a pool entry is replaced only when no active worker covers it and the
|
|
/// snapshot is strictly newer, so delayed snapshots never roll back local
|
|
/// queued/terminal progressions. Returns whether any entry was replaced or
|
|
/// appended.
|
|
pub(crate) fn merge_pool_status_refresh(current: &mut PoolMeta, persisted: PoolMeta, active_workers: &[bool]) -> bool {
|
|
let observed_version = current.version.max(persisted.version);
|
|
if persisted.pools.is_empty() {
|
|
current.version = observed_version;
|
|
return false;
|
|
}
|
|
|
|
if current.pools.is_empty() {
|
|
*current = persisted;
|
|
current.version = observed_version;
|
|
return true;
|
|
}
|
|
|
|
current.version = observed_version;
|
|
|
|
let mut merged_newer = false;
|
|
for (idx, persisted_pool) in persisted.pools.into_iter().enumerate() {
|
|
if persisted_pool.id != idx {
|
|
continue;
|
|
}
|
|
|
|
let has_active_worker = active_workers.get(idx).copied().unwrap_or(false);
|
|
if idx < current.pools.len() {
|
|
if should_replace_pool_status_for_status_refresh(current.pools.get(idx), &persisted_pool, has_active_worker) {
|
|
current.pools[idx] = persisted_pool;
|
|
merged_newer = true;
|
|
}
|
|
} else if idx == current.pools.len() && !has_active_worker {
|
|
current.pools.push(persisted_pool);
|
|
merged_newer = true;
|
|
}
|
|
}
|
|
merged_newer
|
|
}
|
|
|
|
fn merge_pool_meta_updates_for_save(
|
|
persisted: &mut PoolMeta,
|
|
current: &PoolMeta,
|
|
indices: &[usize],
|
|
operation: &str,
|
|
) -> Result<()> {
|
|
if persisted.pools.is_empty() {
|
|
*persisted = current.clone();
|
|
return Ok(());
|
|
}
|
|
if persisted.version != current.version {
|
|
return Err(Error::other(format!(
|
|
"{operation}: pool metadata version changed from {} to {}",
|
|
current.version, persisted.version
|
|
)));
|
|
}
|
|
|
|
for &idx in indices {
|
|
let current_pool = current
|
|
.pools
|
|
.get(idx)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(current.pools.len(), idx))?;
|
|
let persisted_count = persisted.pools.len();
|
|
let persisted_pool = persisted
|
|
.pools
|
|
.get_mut(idx)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(persisted_count, idx))?;
|
|
if current_pool.id != idx || persisted_pool.id != idx || current_pool.cmd_line != persisted_pool.cmd_line {
|
|
return Err(Error::other(format!("{operation}: pool metadata layout changed for pool {idx}")));
|
|
}
|
|
let current_clears_decommission = current_pool
|
|
.decommission
|
|
.as_ref()
|
|
.is_none_or(|info| !info.has_decommission_state());
|
|
if current_clears_decommission
|
|
&& persisted_pool.decommission.as_ref().is_some_and(|info| {
|
|
info.has_decommission_state() && is_decommission_active(info.complete, info.failed, info.canceled)
|
|
})
|
|
{
|
|
record_pool_meta_stale_write_rejection("nonterminal_decommission_clear");
|
|
return Err(Error::other(format!(
|
|
"{operation}: stale pool metadata update rejected for pool {idx}; persisted active or queued decommission cannot be cleared"
|
|
)));
|
|
}
|
|
if current_clears_decommission
|
|
&& persisted_pool
|
|
.decommission
|
|
.as_ref()
|
|
.is_some_and(|info| info.complete || !info.unresolved_entries.is_empty())
|
|
{
|
|
record_pool_meta_stale_write_rejection("unsafe_terminal_decommission_clear");
|
|
return Err(Error::StalePoolMetadataUpdate {
|
|
operation: operation.to_string(),
|
|
pool_index: idx,
|
|
reason: "completed or unresolved decommission state cannot be cleared",
|
|
});
|
|
}
|
|
if current_pool.last_update < persisted_pool.last_update {
|
|
record_pool_meta_stale_write_rejection("older_pool_revision");
|
|
return Err(Error::other(format!(
|
|
"{operation}: stale pool metadata update rejected for pool {idx}; persisted update is newer"
|
|
)));
|
|
}
|
|
if let (Some(persisted_info), Some(current_info)) =
|
|
(persisted_pool.decommission.as_ref(), current_pool.decommission.as_ref())
|
|
&& current_info.has_decommission_state()
|
|
{
|
|
let persisted_terminal = (persisted_info.complete, persisted_info.failed, persisted_info.canceled);
|
|
let current_terminal = (current_info.complete, current_info.failed, current_info.canceled);
|
|
if persisted_terminal != (false, false, false) && persisted_terminal != current_terminal {
|
|
record_pool_meta_stale_write_rejection("terminal_state_regression");
|
|
return Err(Error::other(format!(
|
|
"{operation}: stale pool metadata update rejected for pool {idx}; terminal state cannot be replaced"
|
|
)));
|
|
}
|
|
if current_info.items_decommissioned < persisted_info.items_decommissioned
|
|
|| current_info.items_decommission_failed < persisted_info.items_decommission_failed
|
|
|| current_info.bytes_done < persisted_info.bytes_done
|
|
|| current_info.bytes_failed < persisted_info.bytes_failed
|
|
{
|
|
record_pool_meta_stale_write_rejection("progress_regression");
|
|
return Err(Error::other(format!(
|
|
"{operation}: stale pool metadata update rejected for pool {idx}; durable progress cannot decrease"
|
|
)));
|
|
}
|
|
}
|
|
*persisted_pool = current_pool.clone();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn publish_pool_meta_updates(current: &mut PoolMeta, saved: &PoolMeta, indices: &[usize]) {
|
|
current.version = current.version.max(saved.version);
|
|
for &idx in indices {
|
|
let Some(saved_pool) = saved.pools.get(idx) else {
|
|
continue;
|
|
};
|
|
let Some(current_pool) = current.pools.get_mut(idx) else {
|
|
continue;
|
|
};
|
|
if current_pool.id == idx && saved_pool.id == idx && current_pool.cmd_line == saved_pool.cmd_line {
|
|
*current_pool = saved_pool.clone();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolve_start_decommission_pool_meta_reload_result(result: Result<()>) -> Result<()> {
|
|
resolve_decommission_pool_meta_reload_result(result, "start_decommission")
|
|
}
|
|
|
|
fn activation_rebalance_meta_lock_error(err: rustfs_lock::LockError) -> Error {
|
|
match err {
|
|
rustfs_lock::LockError::QuorumNotReached { required, achieved } => Error::NamespaceLockQuorumUnavailable {
|
|
mode: "write",
|
|
bucket: RUSTFS_META_BUCKET.to_string(),
|
|
object: REBAL_META_NAME.to_string(),
|
|
required,
|
|
achieved,
|
|
},
|
|
other => Error::other(format!(
|
|
"failed to acquire rebalance activation lock on {RUSTFS_META_BUCKET}/{REBAL_META_NAME}: {other}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn activation_pool_meta_lock_error(err: rustfs_lock::LockError) -> Error {
|
|
match err {
|
|
rustfs_lock::LockError::QuorumNotReached { required, achieved } => Error::NamespaceLockQuorumUnavailable {
|
|
mode: "write",
|
|
bucket: RUSTFS_META_BUCKET.to_string(),
|
|
object: POOL_META_NAME.to_string(),
|
|
required,
|
|
achieved,
|
|
},
|
|
other => Error::other(format!(
|
|
"failed to acquire pool activation lock on {RUSTFS_META_BUCKET}/{POOL_META_NAME}: {other}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub(crate) struct PoolRebalanceActivationFence {
|
|
pool_meta_guard: rustfs_lock::NamespaceLockGuard,
|
|
rebalance_meta_guard: rustfs_lock::NamespaceLockGuard,
|
|
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
|
decommission_target_fence_proof: Option<crate::services::notification_sys::DecommissionTargetFenceFleetProofToken>,
|
|
#[cfg(test)]
|
|
forced_lost: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl PoolRebalanceActivationFence {
|
|
pub(crate) fn set_fleet_proof(
|
|
&mut self,
|
|
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
|
) {
|
|
self.fleet_proof = fleet_proof;
|
|
}
|
|
|
|
fn set_decommission_target_fence_proof(
|
|
&mut self,
|
|
fleet_proof: Option<crate::services::notification_sys::DecommissionTargetFenceFleetProofToken>,
|
|
) {
|
|
self.decommission_target_fence_proof = fleet_proof;
|
|
}
|
|
|
|
pub(crate) fn ensure_held(&self) -> Result<()> {
|
|
#[cfg(test)]
|
|
let forced_lost = self.forced_lost.load(Ordering::Acquire);
|
|
#[cfg(not(test))]
|
|
let forced_lost = false;
|
|
if forced_lost || self.pool_meta_guard.is_lock_lost() || self.rebalance_meta_guard.is_lock_lost() {
|
|
return Err(Error::other("activation lock lost before metadata commit or worker admission"));
|
|
}
|
|
if self
|
|
.fleet_proof
|
|
.as_ref()
|
|
.is_some_and(|proof| !crate::services::notification_sys::cross_pool_fence_fleet_proof_matches(proof))
|
|
{
|
|
return Err(Error::other(POOL_ACTIVATION_FLEET_PROOF_EXPIRED));
|
|
}
|
|
if self
|
|
.decommission_target_fence_proof
|
|
.as_ref()
|
|
.is_some_and(|proof| !crate::services::notification_sys::decommission_target_fence_fleet_proof_matches(proof))
|
|
{
|
|
return Err(Error::other(DECOMMISSION_TARGET_FLEET_PROOF_EXPIRED));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn add_namespace_lock_fence(&self, opts: &mut ObjectOptions) {
|
|
opts.add_namespace_lock_guard(&self.pool_meta_guard);
|
|
opts.add_namespace_lock_guard(&self.rebalance_meta_guard);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn force_lost_for_test(&self) {
|
|
self.forced_lost.store(true, Ordering::Release);
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn acquire_pool_rebalance_activation_locks<S>(
|
|
pool: Arc<S>,
|
|
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
|
) -> Result<PoolRebalanceActivationFence>
|
|
where
|
|
S: crate::storage_api_contracts::namespace::NamespaceLocking<
|
|
Error = Error,
|
|
NamespaceLock = rustfs_lock::NamespaceLockWrapper,
|
|
>,
|
|
{
|
|
// Activation lock order is always pool.bin -> rebalance.bin.
|
|
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
|
|
let pool_meta_guard = pool_meta_lock
|
|
.get_write_lock(get_lock_acquire_timeout())
|
|
.await
|
|
.map_err(activation_pool_meta_lock_error)?;
|
|
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
|
|
let rebalance_meta_guard = rebalance_meta_lock
|
|
.get_write_lock(get_lock_acquire_timeout())
|
|
.await
|
|
.map_err(activation_rebalance_meta_lock_error)?;
|
|
|
|
Ok(PoolRebalanceActivationFence {
|
|
pool_meta_guard,
|
|
rebalance_meta_guard,
|
|
fleet_proof,
|
|
decommission_target_fence_proof: None,
|
|
#[cfg(test)]
|
|
forced_lost: Arc::new(AtomicBool::new(false)),
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn acquire_pool_activation_fleet_proof(
|
|
ctx: &crate::runtime::instance::InstanceContext,
|
|
) -> Result<Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>> {
|
|
if !ctx.is_dist_erasure().await {
|
|
return Ok(None);
|
|
}
|
|
crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof()
|
|
.map(Some)
|
|
.ok_or_else(|| Error::other(POOL_ACTIVATION_FLEET_PROOF_REQUIRED))
|
|
}
|
|
|
|
pub(crate) fn is_pool_activation_fleet_proof_error(err: &Error) -> bool {
|
|
// Save-stage helpers add context by formatting the original error, so the
|
|
// marker may be nested in the display string. Restrict matching to the
|
|
// `Error::other` I/O shape used by this activation path.
|
|
matches!(err, Error::Io(io_error) if io_error.kind() == std::io::ErrorKind::Other && {
|
|
let message = io_error.to_string();
|
|
message.contains(POOL_ACTIVATION_FLEET_PROOF_REQUIRED) || message.contains(POOL_ACTIVATION_FLEET_PROOF_EXPIRED)
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum PoolActivationStartKind {
|
|
Rebalance,
|
|
Decommission,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
struct PoolActivationDurableSaveBarrierState {
|
|
pool_key: usize,
|
|
arrived: tokio::sync::Notify,
|
|
release: tokio::sync::Notify,
|
|
force_fence_loss: AtomicBool,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
static POOL_ACTIVATION_DURABLE_SAVE_BARRIER: std::sync::OnceLock<
|
|
std::sync::Mutex<Option<Arc<PoolActivationDurableSaveBarrierState>>>,
|
|
> = std::sync::OnceLock::new();
|
|
|
|
#[cfg(test)]
|
|
pub(crate) struct PoolActivationDurableSaveBarrier {
|
|
state: Arc<PoolActivationDurableSaveBarrierState>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn pool_activation_test_pool_key<S>(pool: &Arc<S>) -> usize {
|
|
Arc::as_ptr(pool).cast::<()>() as usize
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl PoolActivationDurableSaveBarrier {
|
|
pub(crate) fn install<S>(pool: &Arc<S>) -> Self {
|
|
let state = Arc::new(PoolActivationDurableSaveBarrierState {
|
|
pool_key: pool_activation_test_pool_key(pool),
|
|
arrived: tokio::sync::Notify::new(),
|
|
release: tokio::sync::Notify::new(),
|
|
force_fence_loss: AtomicBool::new(false),
|
|
});
|
|
let mut barrier = POOL_ACTIVATION_DURABLE_SAVE_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("pool activation durable save barrier should not be poisoned");
|
|
assert!(barrier.is_none(), "pool activation durable save barrier must be unique");
|
|
*barrier = Some(Arc::clone(&state));
|
|
Self { state }
|
|
}
|
|
|
|
pub(crate) async fn wait_until_paused(&self) {
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), self.state.arrived.notified())
|
|
.await
|
|
.expect("activation should reach the post-durable-save barrier");
|
|
}
|
|
|
|
pub(crate) fn release_after_fence_loss(&self) {
|
|
self.state.force_fence_loss.store(true, Ordering::Release);
|
|
self.state.release.notify_one();
|
|
}
|
|
|
|
pub(crate) fn release_without_fence_loss(&self) {
|
|
self.state.release.notify_one();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Drop for PoolActivationDurableSaveBarrier {
|
|
fn drop(&mut self) {
|
|
self.state.release.notify_one();
|
|
let mut barrier = POOL_ACTIVATION_DURABLE_SAVE_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("pool activation durable save barrier should not be poisoned");
|
|
if barrier.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
|
*barrier = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn pause_pool_activation_after_durable_save<S>(pool: &Arc<S>, fence: &PoolRebalanceActivationFence) {
|
|
let pool_key = pool_activation_test_pool_key(pool);
|
|
let barrier = {
|
|
let mut barrier = POOL_ACTIVATION_DURABLE_SAVE_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("pool activation durable save barrier should not be poisoned");
|
|
if barrier.as_ref().is_some_and(|state| state.pool_key == pool_key) {
|
|
barrier.take()
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
if let Some(barrier) = barrier {
|
|
barrier.arrived.notify_one();
|
|
barrier.release.notified().await;
|
|
if barrier.force_fence_loss.load(Ordering::Acquire) {
|
|
fence.force_lost_for_test();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
struct PoolActivationStartProbeState {
|
|
kind: PoolActivationStartKind,
|
|
preflight_side_effect_attempted: std::sync::atomic::AtomicBool,
|
|
attempted: std::sync::atomic::AtomicBool,
|
|
notify: tokio::sync::Notify,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
static POOL_ACTIVATION_START_PROBES: std::sync::OnceLock<std::sync::Mutex<Vec<Arc<PoolActivationStartProbeState>>>> =
|
|
std::sync::OnceLock::new();
|
|
|
|
#[cfg(test)]
|
|
pub(crate) struct PoolActivationStartProbe {
|
|
state: Arc<PoolActivationStartProbeState>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl PoolActivationStartProbe {
|
|
pub(crate) fn install(kind: PoolActivationStartKind) -> Self {
|
|
let state = Arc::new(PoolActivationStartProbeState {
|
|
kind,
|
|
preflight_side_effect_attempted: std::sync::atomic::AtomicBool::new(false),
|
|
attempted: std::sync::atomic::AtomicBool::new(false),
|
|
notify: tokio::sync::Notify::new(),
|
|
});
|
|
POOL_ACTIVATION_START_PROBES
|
|
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
|
.lock()
|
|
.expect("pool activation start probe should not be poisoned")
|
|
.push(Arc::clone(&state));
|
|
Self { state }
|
|
}
|
|
|
|
pub(crate) async fn wait_until_attempted(&self) {
|
|
while !self.state.attempted.load(Ordering::Acquire) {
|
|
self.state.notify.notified().await;
|
|
}
|
|
}
|
|
|
|
pub(crate) fn preflight_side_effect_was_attempted(&self) -> bool {
|
|
self.state.preflight_side_effect_attempted.load(Ordering::Acquire)
|
|
}
|
|
|
|
pub(crate) fn activation_was_attempted(&self) -> bool {
|
|
self.state.attempted.load(Ordering::Acquire)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Drop for PoolActivationStartProbe {
|
|
fn drop(&mut self) {
|
|
let mut probes = POOL_ACTIVATION_START_PROBES
|
|
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
|
.lock()
|
|
.expect("pool activation start probe should not be poisoned");
|
|
probes.retain(|state| !Arc::ptr_eq(state, &self.state));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn observe_pool_activation_start_attempt(kind: PoolActivationStartKind) {
|
|
let probes = POOL_ACTIVATION_START_PROBES
|
|
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
|
.lock()
|
|
.expect("pool activation start probe should not be poisoned")
|
|
.iter()
|
|
.filter(|state| state.kind == kind)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
for state in probes {
|
|
state.attempted.store(true, Ordering::Release);
|
|
state.notify.notify_one();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn observe_pool_activation_preflight_side_effect_attempt(kind: PoolActivationStartKind) {
|
|
let probes = POOL_ACTIVATION_START_PROBES
|
|
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
|
.lock()
|
|
.expect("pool activation start probe should not be poisoned")
|
|
.iter()
|
|
.filter(|state| state.kind == kind)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
for state in probes {
|
|
state.preflight_side_effect_attempted.store(true, Ordering::Release);
|
|
}
|
|
}
|
|
|
|
fn rollback_decommission_pool_meta(pool_meta: &mut PoolMeta, previous_pool_meta: &PoolMeta, indices: &[usize]) {
|
|
publish_pool_meta_updates(pool_meta, previous_pool_meta, indices);
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct DecommissionCancelCommit {
|
|
previous_start_time: Option<OffsetDateTime>,
|
|
previous_queued: bool,
|
|
previous_last_update: OffsetDateTime,
|
|
canceled_pool: PoolStatus,
|
|
}
|
|
|
|
fn commit_decommission_cancel(pool_meta: &mut PoolMeta, idx: usize, commit: DecommissionCancelCommit) -> Result<()> {
|
|
let pool_count = pool_meta.pools.len();
|
|
let Some(current) = pool_meta.pools.get(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
// A peer reload can install the saved cancel while runtime-only fields are reconstructed.
|
|
let cancel_already_published = commit
|
|
.canceled_pool
|
|
.decommission
|
|
.as_ref()
|
|
.is_some_and(|info| info.canceled && !info.complete && !info.failed && info.start_time.is_none())
|
|
&& PersistedPoolStatus::from(current) == PersistedPoolStatus::from(&commit.canceled_pool);
|
|
if cancel_already_published {
|
|
return Ok(());
|
|
}
|
|
|
|
let matches_generation = current.id == commit.canceled_pool.id
|
|
&& current.cmd_line == commit.canceled_pool.cmd_line
|
|
&& current.decommission.as_ref().is_some_and(|info| {
|
|
info.start_time == commit.previous_start_time
|
|
&& info.queued == commit.previous_queued
|
|
&& is_decommission_active(info.complete, info.failed, info.canceled)
|
|
&& (commit.previous_start_time.is_some() || current.last_update == commit.previous_last_update)
|
|
});
|
|
if !matches_generation {
|
|
return Err(Error::other(format!(
|
|
"failed to publish decommission cancel for pool {idx}: operation generation changed"
|
|
)));
|
|
}
|
|
|
|
pool_meta.pools[idx] = commit.canceled_pool;
|
|
Ok(())
|
|
}
|
|
|
|
fn rollback_start_decommission_pool_meta(pool_meta: &mut PoolMeta, previous_pool_meta: &PoolMeta, indices: &[usize]) {
|
|
let active_updates = indices
|
|
.iter()
|
|
.filter_map(|&idx| pool_meta.pools.get(idx).map(|pool| (idx, pool.last_update)))
|
|
.collect::<Vec<_>>();
|
|
rollback_decommission_pool_meta(pool_meta, previous_pool_meta, indices);
|
|
let rollback_at = OffsetDateTime::now_utc();
|
|
for (idx, active_update) in active_updates {
|
|
if let Some(pool) = pool_meta.pools.get_mut(idx) {
|
|
pool.last_update = std::cmp::max(rollback_at, active_update + Duration::nanoseconds(1));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ensure_pool_meta_write_fence(guard: &rustfs_lock::NamespaceLockGuard, operation: &str) -> Result<()> {
|
|
if guard.is_lock_lost() {
|
|
return Err(Error::other(format!("{operation}: pool metadata distributed fence was lost")));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_pool_not_left_in_cmdline_after_decommission(position: usize, cmd_line: &str, completed: bool) -> Result<()> {
|
|
if completed {
|
|
return Err(Error::other(format!(
|
|
"pool({}) = {} is decommissioned, please remove from server command line",
|
|
position + 1,
|
|
cmd_line
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_decommission_listing_worker_result(
|
|
set_idx: usize,
|
|
worker_result: std::result::Result<Result<()>, tokio::task::JoinError>,
|
|
) -> Result<()> {
|
|
worker_result.map_err(|err| Error::other(format!("decommission listing worker {set_idx} task join error: {err}")))?
|
|
}
|
|
|
|
fn should_retry_decommission_listing(err: &Error, attempt: usize, max_attempts: usize) -> bool {
|
|
!is_err_bucket_not_found(err) && attempt + 1 < max_attempts
|
|
}
|
|
|
|
async fn wait_decommission_retry_backoff(rx: &CancellationToken, delay: std::time::Duration) -> bool {
|
|
tokio::select! {
|
|
_ = rx.cancelled() => true,
|
|
_ = tokio::time::sleep(delay) => false,
|
|
}
|
|
}
|
|
|
|
fn decommission_retry_backoff_delay(base: std::time::Duration, attempt: usize) -> std::time::Duration {
|
|
base.saturating_mul(u32::try_from(attempt).unwrap_or(u32::MAX))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn run_decommission_listing_with_retry<List, ListFuture>(
|
|
rx: CancellationToken,
|
|
bucket: String,
|
|
cb: ListCallback,
|
|
pool_idx: usize,
|
|
set_idx: usize,
|
|
max_attempts: usize,
|
|
list: List,
|
|
) -> Result<()>
|
|
where
|
|
List: FnMut(ListCallback) -> ListFuture,
|
|
ListFuture: std::future::Future<Output = Result<()>>,
|
|
{
|
|
run_decommission_listing_with_retry_and_drain(rx, bucket, cb, pool_idx, set_idx, max_attempts, list, || async { false }).await
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn run_decommission_listing_with_retry_and_drain<List, ListFuture, Drain, DrainFuture>(
|
|
rx: CancellationToken,
|
|
bucket: String,
|
|
cb: ListCallback,
|
|
pool_idx: usize,
|
|
set_idx: usize,
|
|
max_attempts: usize,
|
|
mut list: List,
|
|
mut drain: Drain,
|
|
) -> Result<()>
|
|
where
|
|
List: FnMut(ListCallback) -> ListFuture,
|
|
ListFuture: std::future::Future<Output = Result<()>>,
|
|
Drain: FnMut() -> DrainFuture,
|
|
DrainFuture: std::future::Future<Output = bool>,
|
|
{
|
|
let max_attempts = max_attempts.max(1);
|
|
|
|
for attempt in 0..max_attempts {
|
|
if rx.is_cancelled() {
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = pool_idx,
|
|
set_index = set_idx,
|
|
bucket = %bucket,
|
|
state = "listing_worker_cancelled",
|
|
"Decommission listing worker cancelled"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = pool_idx,
|
|
set_index = set_idx,
|
|
bucket = %bucket,
|
|
attempt = attempt + 1,
|
|
max_attempts,
|
|
state = "listing_started",
|
|
"Decommission listing started"
|
|
);
|
|
|
|
let list_result = list(cb.clone()).await;
|
|
if drain().await {
|
|
return Ok(());
|
|
}
|
|
|
|
match list_result {
|
|
Ok(()) => {
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = pool_idx,
|
|
set_index = set_idx,
|
|
bucket = %bucket,
|
|
attempt = attempt + 1,
|
|
max_attempts,
|
|
state = "listing_completed",
|
|
"Decommission listing completed"
|
|
);
|
|
return Ok(());
|
|
}
|
|
Err(err) if is_err_bucket_not_found(&err) => {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = pool_idx,
|
|
set_index = set_idx,
|
|
bucket = %bucket,
|
|
attempt = attempt + 1,
|
|
max_attempts,
|
|
state = "listing_bucket_missing",
|
|
"Decommission listing bucket missing"
|
|
);
|
|
return Ok(());
|
|
}
|
|
Err(err) if should_retry_decommission_listing(&err, attempt, max_attempts) => {
|
|
error!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = pool_idx,
|
|
set_index = set_idx,
|
|
bucket = %bucket,
|
|
attempt = attempt + 1,
|
|
max_attempts,
|
|
retry_delay_ms = DECOMMISSION_LISTING_RETRY_DELAY.as_millis(),
|
|
state = "listing_failed_retrying",
|
|
error = ?err,
|
|
"Decommission listing failed; retrying"
|
|
);
|
|
if wait_decommission_retry_backoff(&rx, DECOMMISSION_LISTING_RETRY_DELAY).await {
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = pool_idx,
|
|
set_index = set_idx,
|
|
bucket = %bucket,
|
|
state = "listing_worker_cancelled",
|
|
"Decommission listing worker cancelled during retry wait"
|
|
);
|
|
return Ok(());
|
|
}
|
|
}
|
|
Err(err) => {
|
|
error!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = pool_idx,
|
|
set_index = set_idx,
|
|
bucket = %bucket,
|
|
attempt = attempt + 1,
|
|
max_attempts,
|
|
state = "listing_failed",
|
|
error = ?err,
|
|
"Decommission listing failed"
|
|
);
|
|
return Err(Error::other(format!(
|
|
"decommission listing failed for bucket {bucket} pool {pool_idx} set {set_idx} attempt {}/{}: {err}",
|
|
attempt + 1,
|
|
max_attempts
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn should_count_decommission_version_complete(ignore: bool, cleanup_ignored: bool, failure: bool) -> bool {
|
|
cleanup_ignored || (!ignore && !failure)
|
|
}
|
|
|
|
fn is_decommission_copy_cleanup_safe_error(err: &Error) -> bool {
|
|
// DataMovementOverwriteErr only means source and destination pool resolved to
|
|
// the same pool. Without a target equivalence check it is not cleanup-safe.
|
|
if is_err_object_not_found(err) || is_err_version_not_found(err) {
|
|
return true;
|
|
}
|
|
|
|
// A not-found surfacing from inside a data-movement stage is the same
|
|
// condition once the wrapper is unwrapped (backlog#1827 T2).
|
|
data_movement::data_movement_stage_source(err).is_some_and(is_decommission_copy_cleanup_safe_error)
|
|
}
|
|
|
|
fn is_decommission_target_capacity_error(err: &Error) -> bool {
|
|
if matches!(err, Error::DiskFull | Error::StorageFull) {
|
|
return true;
|
|
}
|
|
|
|
// A stage failure keeps the error it wrapped, so classify by type rather
|
|
// than by the rendered message (backlog#1827 T2). The substring fallback
|
|
// stays for errors that reached here through some other wrapper.
|
|
if let Some(source) = data_movement::data_movement_stage_source(err) {
|
|
return is_decommission_target_capacity_error(source);
|
|
}
|
|
|
|
let message = err.to_string();
|
|
let disk_full = Error::DiskFull.to_string();
|
|
let storage_full = Error::StorageFull.to_string();
|
|
message.contains(&disk_full) || message.contains(&storage_full)
|
|
}
|
|
|
|
fn should_cleanup_decommission_source_entry(decommissioned: usize, total_versions: usize, expired: usize) -> bool {
|
|
decommissioned.saturating_add(expired) == total_versions
|
|
}
|
|
|
|
fn should_fail_decommission_pool_after_exhausted_source_changed(exhausted_entries: usize) -> bool {
|
|
exhausted_entries > DECOMMISSION_SOURCE_CHANGED_EXHAUSTION_LIMIT
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum DecommissionEntryAttemptOutcome {
|
|
Complete,
|
|
SourceChanged,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) type DecommissionTestFaultDecision = Arc<dyn Fn(&'static str, &str, &str, usize, bool) -> bool + Send + Sync>;
|
|
|
|
#[cfg(test)]
|
|
static DECOMMISSION_TEST_FAULT_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<DecommissionTestFaultDecision>>> =
|
|
std::sync::OnceLock::new();
|
|
|
|
#[cfg(test)]
|
|
pub(crate) struct DecommissionTestFaultGuard(DecommissionTestFaultDecision);
|
|
|
|
#[cfg(test)]
|
|
impl DecommissionTestFaultGuard {
|
|
pub(crate) fn install(decision: DecommissionTestFaultDecision) -> Self {
|
|
let mut slot = DECOMMISSION_TEST_FAULT_HOOK
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission test fault hook mutex should not poison");
|
|
assert!(slot.is_none(), "decommission test fault hook must be unique");
|
|
let stored = Arc::clone(&decision);
|
|
*slot = Some(decision);
|
|
Self(stored)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Drop for DecommissionTestFaultGuard {
|
|
fn drop(&mut self) {
|
|
let mut slot = DECOMMISSION_TEST_FAULT_HOOK
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission test fault hook mutex should not poison");
|
|
if slot.as_ref().is_some_and(|decision| Arc::ptr_eq(decision, &self.0)) {
|
|
*slot = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn decommission_test_wrap_result<T>(
|
|
stage: &'static str,
|
|
bucket: &str,
|
|
object: &str,
|
|
attempt: usize,
|
|
result: Result<T>,
|
|
) -> Result<T> {
|
|
let decision = DECOMMISSION_TEST_FAULT_HOOK
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission test fault hook mutex should not poison")
|
|
.clone();
|
|
let inject = decision.is_some_and(|decision| decision(stage, bucket, object, attempt, result.is_ok()));
|
|
if result.is_ok() && inject {
|
|
return Err(Error::other(format!(
|
|
"injected decommission test fault at {stage} attempt {attempt} for {bucket}/{object}"
|
|
)));
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) type DecommissionCleanupMutationHook = Arc<dyn Fn(&str, &str, usize) -> BoxFuture<'static, ()> + Send + Sync>;
|
|
|
|
#[cfg(test)]
|
|
static DECOMMISSION_CLEANUP_MUTATION_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<DecommissionCleanupMutationHook>>> =
|
|
std::sync::OnceLock::new();
|
|
|
|
#[cfg(test)]
|
|
pub(crate) struct DecommissionCleanupMutationGuard(DecommissionCleanupMutationHook);
|
|
|
|
#[cfg(test)]
|
|
impl DecommissionCleanupMutationGuard {
|
|
pub(crate) fn install(hook: DecommissionCleanupMutationHook) -> Self {
|
|
let mut slot = DECOMMISSION_CLEANUP_MUTATION_HOOK
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission cleanup mutation hook mutex should not poison");
|
|
assert!(slot.is_none(), "decommission cleanup mutation hook must be unique");
|
|
let stored = Arc::clone(&hook);
|
|
*slot = Some(hook);
|
|
Self(stored)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Drop for DecommissionCleanupMutationGuard {
|
|
fn drop(&mut self) {
|
|
let mut slot = DECOMMISSION_CLEANUP_MUTATION_HOOK
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission cleanup mutation hook mutex should not poison");
|
|
if slot.as_ref().is_some_and(|hook| Arc::ptr_eq(hook, &self.0)) {
|
|
*slot = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn run_decommission_cleanup_mutation_hook(bucket: &str, object: &str, attempt: usize) {
|
|
let hook = DECOMMISSION_CLEANUP_MUTATION_HOOK
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission cleanup mutation hook mutex should not poison")
|
|
.clone();
|
|
if let Some(hook) = hook {
|
|
hook(bucket, object, attempt).await;
|
|
}
|
|
}
|
|
|
|
const DECOMMISSION_FREE_VERSION_MIGRATED_REASON: &str = "tier_free_version_migrated";
|
|
const DECOMMISSION_FREE_VERSION_CONSUMED_REASON: &str = "tier_free_version_already_consumed";
|
|
const DECOMMISSION_FREE_VERSION_RETAINED_REASON: &str = "tier_free_version_migration_failed";
|
|
const DECOMMISSION_FREE_VERSION_SWEEP_REASON: &str = "tier_free_version_unresolved_after_decommission";
|
|
const DECOMMISSION_FREE_VERSION_DISPOSITION_REASON: &str = "tier_free_version_disposition_recorded";
|
|
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
struct DecommissionFreeVersionDisposition {
|
|
migrated: usize,
|
|
consumed: usize,
|
|
retained: usize,
|
|
}
|
|
|
|
impl DecommissionFreeVersionDisposition {
|
|
fn record_migrated(&mut self) {
|
|
self.migrated += 1;
|
|
}
|
|
|
|
fn record_consumed(&mut self) {
|
|
self.consumed += 1;
|
|
}
|
|
|
|
fn record_retained(&mut self) {
|
|
self.retained += 1;
|
|
}
|
|
|
|
fn total(self) -> usize {
|
|
self.migrated.saturating_add(self.consumed).saturating_add(self.retained)
|
|
}
|
|
}
|
|
|
|
enum DecommissionFreeVersionAttempt {
|
|
Migrated,
|
|
Consumed,
|
|
CapacityFailure(Error),
|
|
Retry(Error),
|
|
}
|
|
|
|
fn classify_decommission_free_version_attempt(result: Result<()>) -> DecommissionFreeVersionAttempt {
|
|
match result {
|
|
Ok(()) => DecommissionFreeVersionAttempt::Migrated,
|
|
Err(err) if is_decommission_copy_cleanup_safe_error(&err) => DecommissionFreeVersionAttempt::Consumed,
|
|
Err(err) if is_decommission_target_capacity_error(&err) => DecommissionFreeVersionAttempt::CapacityFailure(err),
|
|
Err(err) => DecommissionFreeVersionAttempt::Retry(err),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
#[allow(
|
|
dead_code,
|
|
reason = "terminal-state classification asserted by this file's tests (backlog#1823)"
|
|
)]
|
|
enum DecommissionTerminalState {
|
|
Completed,
|
|
Failed,
|
|
}
|
|
|
|
#[allow(
|
|
dead_code,
|
|
reason = "terminal-state classification asserted by this file's tests (backlog#1823)"
|
|
)]
|
|
fn classify_decommission_terminal_state(failed_items_present: bool) -> DecommissionTerminalState {
|
|
if failed_items_present {
|
|
DecommissionTerminalState::Failed
|
|
} else {
|
|
DecommissionTerminalState::Completed
|
|
}
|
|
}
|
|
|
|
fn should_preserve_decommission_canceled_state(meta_canceled: bool, _cancel_signal: bool) -> bool {
|
|
meta_canceled
|
|
}
|
|
|
|
fn should_continue_decommission_queue(meta: &PoolMeta, idx: usize) -> bool {
|
|
meta.pools
|
|
.get(idx)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.is_some_and(|info| info.complete && !info.failed && !info.canceled)
|
|
}
|
|
|
|
fn decommission_cancel_signal_result(cancel_signal: bool) -> Result<()> {
|
|
if cancel_signal {
|
|
Err(StorageError::OperationCanceled)
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn is_decommission_cancel_requested(cancel_signal: bool, pool: Option<&PoolStatus>) -> bool {
|
|
cancel_signal
|
|
|| pool
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.is_some_and(|info| info.canceled)
|
|
}
|
|
|
|
fn should_skip_canceled_decommission_routine(cancel_signal: bool, pool: Option<&PoolStatus>) -> bool {
|
|
cancel_signal
|
|
&& pool
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.is_some_and(|info| info.canceled)
|
|
}
|
|
|
|
async fn run_decommission_buckets_bounded<F>(
|
|
rx: CancellationToken,
|
|
buckets: Vec<DecomBucketInfo>,
|
|
limit: usize,
|
|
mut start_bucket: F,
|
|
) -> Result<()>
|
|
where
|
|
F: FnMut(DecomBucketInfo, CancellationToken) -> BoxFuture<'static, Result<()>>,
|
|
{
|
|
let mut pending = buckets.into_iter();
|
|
let mut active: FuturesUnordered<BoxFuture<'static, Result<()>>> = FuturesUnordered::new();
|
|
let mut first_err = None;
|
|
let limit = limit.max(1);
|
|
|
|
for _ in 0..limit {
|
|
let Some(bucket) = pending.next() else {
|
|
break;
|
|
};
|
|
|
|
active.push(start_bucket(bucket, rx.clone()));
|
|
}
|
|
|
|
while let Some(result) = active.next().await {
|
|
if let Err(err) = result {
|
|
rx.cancel();
|
|
if first_err.is_none() {
|
|
first_err = Some(err);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if first_err.is_some() || rx.is_cancelled() {
|
|
continue;
|
|
}
|
|
|
|
let Some(bucket) = pending.next() else {
|
|
continue;
|
|
};
|
|
|
|
active.push(start_bucket(bucket, rx.clone()));
|
|
}
|
|
|
|
if first_err.is_none() && rx.is_cancelled() && pending.len() > 0 {
|
|
return decommission_cancel_signal_result(true);
|
|
}
|
|
|
|
if let Some(err) = first_err {
|
|
return Err(err);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_decommission_phases<F>(
|
|
rx: CancellationToken,
|
|
regular_buckets: Vec<DecomBucketInfo>,
|
|
meta_buckets: Vec<DecomBucketInfo>,
|
|
bucket_concurrency: usize,
|
|
mut start_bucket: F,
|
|
) -> Result<()>
|
|
where
|
|
F: FnMut(DecomBucketInfo, CancellationToken) -> BoxFuture<'static, Result<()>>,
|
|
{
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
|
|
for bucket in meta_buckets {
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
start_bucket(bucket, rx.clone()).await?;
|
|
}
|
|
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
|
|
if bucket_concurrency <= 1 {
|
|
for bucket in regular_buckets {
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
start_bucket(bucket, rx.clone()).await?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
run_decommission_buckets_bounded(rx, regular_buckets, bucket_concurrency, start_bucket).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn wait_decommission_worker_drain(workers: &Semaphore, limit: usize) -> Result<()> {
|
|
let permits = u32::try_from(limit)
|
|
.map_err(|_| Error::other(format!("decommission worker limit {limit} exceeds semaphore drain capacity")))?;
|
|
let _drain = workers
|
|
.acquire_many(permits)
|
|
.await
|
|
.map_err(|err| Error::other(format!("decommission worker drain failed: {err}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn should_reject_decommission_cancel_as_terminal(complete: bool, failed: bool) -> bool {
|
|
complete || failed
|
|
}
|
|
|
|
fn should_retry_decommission_cancel_reload(changed: bool, already_canceled: bool) -> bool {
|
|
changed || already_canceled
|
|
}
|
|
|
|
fn ensure_decommission_cancel_allowed(pool_present: bool, decommission_present: bool, terminal: bool) -> Result<()> {
|
|
if !pool_present {
|
|
return Err(Error::other("failed to cancel decommission: target pool was not found"));
|
|
}
|
|
|
|
if !decommission_present || terminal {
|
|
return Err(StorageError::DecommissionNotStarted);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_decommission_clear_allowed(
|
|
pool_present: bool,
|
|
decommission_present: bool,
|
|
complete: bool,
|
|
failed: bool,
|
|
canceled: bool,
|
|
unresolved_entries: usize,
|
|
) -> Result<()> {
|
|
if !pool_present {
|
|
return Err(Error::other("failed to clear decommission: target pool was not found"));
|
|
}
|
|
|
|
if !decommission_present {
|
|
return Err(StorageError::DecommissionNotStarted);
|
|
}
|
|
|
|
if complete {
|
|
return Err(StorageError::DecommissionNotStarted);
|
|
}
|
|
|
|
if !failed && !canceled {
|
|
return Err(StorageError::DecommissionAlreadyRunning);
|
|
}
|
|
|
|
if unresolved_entries > 0 {
|
|
return Err(Error::other(format!(
|
|
"failed to clear decommission: {unresolved_entries} unresolved listing entries must be reconciled by retrying decommission"
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_decommission_terminal_operation_supported(single_pool: bool, operation: &str) -> Result<()> {
|
|
if single_pool {
|
|
return Err(Error::other(format!(
|
|
"failed to {operation}: single pool deployments do not support decommission"
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_start_decommission_request(indices: &[usize], single_pool: bool) -> Result<()> {
|
|
if indices.is_empty() {
|
|
return Err(Error::other("failed to start decommission: no target pools were provided"));
|
|
}
|
|
|
|
ensure_decommission_terminal_operation_supported(single_pool, "start decommission")
|
|
}
|
|
|
|
fn require_decommission_store<T>(store: Option<T>, operation: &str) -> Result<T> {
|
|
store.ok_or_else(|| Error::other(format!("failed to {operation}: store not initialized")))
|
|
}
|
|
|
|
fn ensure_decommission_listing_disks_available(has_disks: bool, bucket: &str) -> Result<()> {
|
|
if !has_disks {
|
|
return Err(Error::other(format!(
|
|
"failed to list objects to decommission for bucket {bucket}: no disks available"
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PoolStatus {
|
|
#[serde(rename = "id")]
|
|
pub id: usize,
|
|
#[serde(rename = "cmdline")]
|
|
pub cmd_line: String,
|
|
#[serde(rename = "lastUpdate", with = "time::serde::rfc3339")]
|
|
pub last_update: OffsetDateTime,
|
|
#[serde(rename = "decommissionInfo")]
|
|
pub decommission: Option<PoolDecommissionInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct PoolMeta {
|
|
pub version: u16,
|
|
pub pools: Vec<PoolStatus>,
|
|
pub dont_save: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct PoolMetaRevision {
|
|
version: u16,
|
|
cluster_id: Option<uuid::Uuid>,
|
|
epoch: u64,
|
|
generation: u64,
|
|
transaction_id: Option<uuid::Uuid>,
|
|
}
|
|
|
|
impl PoolMetaRevision {
|
|
fn legacy(version: u16) -> Self {
|
|
Self {
|
|
version,
|
|
cluster_id: None,
|
|
epoch: 0,
|
|
generation: 0,
|
|
transaction_id: None,
|
|
}
|
|
}
|
|
|
|
fn is_generation_protocol(self) -> bool {
|
|
self.version == POOL_META_GENERATION_VERSION
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct PoolMetaCommittedCandidate {
|
|
canonical: Vec<u8>,
|
|
meta: PoolMeta,
|
|
revision: PoolMetaRevision,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum PoolMetaReplica {
|
|
Missing,
|
|
Valid {
|
|
raw: Vec<u8>,
|
|
canonical: Vec<u8>,
|
|
meta: PoolMeta,
|
|
revision: PoolMetaRevision,
|
|
committed: bool,
|
|
previous: Option<Box<PoolMetaCommittedCandidate>>,
|
|
},
|
|
Corrupt(String),
|
|
Incompatible(String),
|
|
Unreadable(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum PoolMetaCasToken {
|
|
Missing,
|
|
Existing(String),
|
|
Unsafe,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PoolMetaReplicaRead {
|
|
replica: PoolMetaReplica,
|
|
cas: PoolMetaCasToken,
|
|
}
|
|
|
|
impl From<PoolMetaReplica> for PoolMetaReplicaRead {
|
|
fn from(replica: PoolMetaReplica) -> Self {
|
|
Self {
|
|
replica,
|
|
cas: PoolMetaCasToken::Unsafe,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct PoolMetaReplicaState {
|
|
pub(crate) needs_repair: bool,
|
|
pub(crate) repair_write_safe: bool,
|
|
}
|
|
|
|
impl PoolMetaReplicaState {
|
|
pub(crate) fn ensure_write_safe(self, operation: &str) -> Result<()> {
|
|
if self.repair_write_safe {
|
|
return Ok(());
|
|
}
|
|
Err(Error::other(format!(
|
|
"{operation}: pool metadata update cannot overwrite an unreadable replica"
|
|
)))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub(crate) struct PoolMetaWriteState {
|
|
write_blocked: bool,
|
|
aborted_transaction: Arc<AtomicBool>,
|
|
expected_cluster_id: Option<uuid::Uuid>,
|
|
cluster_epoch: Option<u64>,
|
|
pool_meta_absent: bool,
|
|
bootstrap_authority: PoolMetaBootstrapAuthority,
|
|
identity_initialized: Option<bool>,
|
|
identity_fresh_bootstrap_nonce: Option<uuid::Uuid>,
|
|
identity_needs_repair: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub(crate) enum PoolMetaBootstrapAuthority {
|
|
#[default]
|
|
None,
|
|
Fresh,
|
|
LegacyAdoption,
|
|
}
|
|
|
|
impl PoolMetaBootstrapAuthority {
|
|
pub(crate) fn combine_across_pools(self, other: Self) -> Self {
|
|
if self == other { self } else { Self::None }
|
|
}
|
|
|
|
fn is_proven(self) -> bool {
|
|
!matches!(self, Self::None)
|
|
}
|
|
}
|
|
|
|
impl PoolMetaWriteState {
|
|
#[cfg(test)]
|
|
pub(crate) fn for_startup(cluster_id: uuid::Uuid, fresh_bootstrap_proven: bool) -> Self {
|
|
let bootstrap_authority = if fresh_bootstrap_proven {
|
|
PoolMetaBootstrapAuthority::Fresh
|
|
} else {
|
|
PoolMetaBootstrapAuthority::None
|
|
};
|
|
Self::for_startup_with_bootstrap_authority(cluster_id, bootstrap_authority)
|
|
}
|
|
|
|
pub(crate) fn for_startup_with_bootstrap_authority(
|
|
cluster_id: uuid::Uuid,
|
|
bootstrap_authority: PoolMetaBootstrapAuthority,
|
|
) -> Self {
|
|
Self {
|
|
expected_cluster_id: Some(cluster_id),
|
|
bootstrap_authority,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub(crate) fn bootstrap_identity_proven(&self) -> bool {
|
|
self.bootstrap_authority.is_proven()
|
|
}
|
|
|
|
pub(crate) fn identity_is_pending(&self) -> bool {
|
|
self.identity_initialized == Some(false)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn aborted_transaction_latch_for_test(&self) -> Arc<AtomicBool> {
|
|
Arc::clone(&self.aborted_transaction)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn independent_clone_for_test(&self) -> Self {
|
|
let mut cloned = self.clone();
|
|
cloned.aborted_transaction = Arc::new(AtomicBool::new(self.aborted_transaction.load(Ordering::Acquire)));
|
|
cloned
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
fn for_test_bootstrap() -> Self {
|
|
Self {
|
|
bootstrap_authority: PoolMetaBootstrapAuthority::Fresh,
|
|
identity_initialized: Some(false),
|
|
identity_fresh_bootstrap_nonce: Some(uuid::Uuid::new_v4()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub(crate) fn observe_replicas(&mut self, replica_state: PoolMetaReplicaState) {
|
|
self.write_blocked |= !replica_state.repair_write_safe;
|
|
}
|
|
|
|
fn block_writes(&mut self) {
|
|
self.write_blocked = true;
|
|
}
|
|
|
|
pub(crate) fn block_writes_after_fence_loss(&mut self) {
|
|
self.block_writes();
|
|
}
|
|
|
|
fn arm_transaction(&self) -> PoolMetaTransactionArm {
|
|
PoolMetaTransactionArm {
|
|
aborted_transaction: Arc::clone(&self.aborted_transaction),
|
|
armed: true,
|
|
}
|
|
}
|
|
|
|
fn observe_selection(&mut self, selection: &PoolMetaSelection) -> Result<()> {
|
|
self.pool_meta_absent = selection.absent;
|
|
if let Some(expected_cluster_id) = self.expected_cluster_id
|
|
&& let Some((cluster_id, _)) = selection.generation_identity
|
|
&& cluster_id != expected_cluster_id
|
|
{
|
|
self.block_writes();
|
|
return Err(Error::other(format!(
|
|
"pool metadata incompatible: cluster identity {cluster_id} does not match deployment {expected_cluster_id}"
|
|
)));
|
|
}
|
|
if let Some(identity_epoch) = self.cluster_epoch
|
|
&& let Some((_, metadata_epoch)) = selection.generation_identity
|
|
&& metadata_epoch != identity_epoch
|
|
{
|
|
self.block_writes();
|
|
return Err(Error::other(format!(
|
|
"pool metadata recovery required: committed epoch {} does not match cluster identity epoch {identity_epoch}",
|
|
metadata_epoch
|
|
)));
|
|
}
|
|
if self.cluster_epoch.is_none()
|
|
&& let Some((_, metadata_epoch)) = selection.generation_identity
|
|
{
|
|
self.cluster_epoch = Some(metadata_epoch);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn observe_identity(&mut self, selection: &PoolMetaIdentitySelection) -> Result<()> {
|
|
self.identity_needs_repair = selection.needs_repair;
|
|
self.identity_initialized = selection.identity.map(|identity| identity.initialized);
|
|
self.identity_fresh_bootstrap_nonce = selection.identity.and_then(|identity| identity.fresh_bootstrap_nonce);
|
|
if let Some(identity) = selection.identity {
|
|
if identity.initialized {
|
|
self.bootstrap_authority = PoolMetaBootstrapAuthority::None;
|
|
}
|
|
if let Some(metadata_epoch) = self.cluster_epoch
|
|
&& metadata_epoch != identity.epoch
|
|
{
|
|
self.block_writes();
|
|
return Err(Error::other(format!(
|
|
"pool metadata recovery required: metadata epoch {metadata_epoch} does not match cluster identity epoch {}",
|
|
identity.epoch
|
|
)));
|
|
}
|
|
self.cluster_epoch = Some(identity.epoch);
|
|
}
|
|
if !selection.repair_write_safe {
|
|
self.block_writes();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn ensure_missing_metadata_can_initialize(&mut self) -> Result<()> {
|
|
if !self.pool_meta_absent {
|
|
return Ok(());
|
|
}
|
|
match self.identity_initialized {
|
|
Some(false) if self.bootstrap_identity_proven() && self.identity_fresh_bootstrap_nonce.is_some() => Ok(()),
|
|
Some(false) => {
|
|
self.block_writes();
|
|
Err(Error::other(
|
|
"pool metadata recovery required: pending cluster identity exists but this startup has no verified fresh-bootstrap proof or legacy-adoption proof",
|
|
))
|
|
}
|
|
Some(true) => {
|
|
self.block_writes();
|
|
Err(Error::other(
|
|
"pool metadata recovery required: initialized cluster identity exists but every pool.bin replica is missing",
|
|
))
|
|
}
|
|
None => {
|
|
self.block_writes();
|
|
Err(Error::other(
|
|
"pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available",
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn identity_requires_repair(&self) -> bool {
|
|
self.expected_cluster_id.is_some() && (self.identity_needs_repair || self.identity_initialized != Some(true))
|
|
}
|
|
|
|
pub(crate) fn ensure_write_safe(&self, operation: &str) -> Result<()> {
|
|
if !self.write_blocked && !self.aborted_transaction.load(Ordering::SeqCst) {
|
|
return Ok(());
|
|
}
|
|
Err(Error::other(format!(
|
|
"{operation}: pool metadata writes remain blocked after a recovery-required replica state; restart after all replicas are readable and consistent, with compatible formats"
|
|
)))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PoolMetaTransactionArm {
|
|
aborted_transaction: Arc<AtomicBool>,
|
|
armed: bool,
|
|
}
|
|
|
|
impl PoolMetaTransactionArm {
|
|
fn disarm(&mut self) {
|
|
self.armed = false;
|
|
}
|
|
}
|
|
|
|
impl Drop for PoolMetaTransactionArm {
|
|
fn drop(&mut self) {
|
|
if self.armed {
|
|
self.aborted_transaction.store(true, Ordering::SeqCst);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PoolMetaSelection {
|
|
meta: PoolMeta,
|
|
revision: PoolMetaRevision,
|
|
canonical: Option<Vec<u8>>,
|
|
replica_state: PoolMetaReplicaState,
|
|
cas_tokens: Vec<PoolMetaCasToken>,
|
|
absent: bool,
|
|
generation_protocol_observed: bool,
|
|
generation_identity: Option<(uuid::Uuid, u64)>,
|
|
}
|
|
|
|
fn classify_pool_meta_tuple_decode_error(kind: &str, err: rmp_serde::decode::Error) -> PoolMetaReplica {
|
|
let truncated = matches!(
|
|
&err,
|
|
rmp_serde::decode::Error::InvalidMarkerRead(source)
|
|
| rmp_serde::decode::Error::InvalidDataRead(source)
|
|
if source.kind() == std::io::ErrorKind::UnexpectedEof
|
|
);
|
|
if truncated {
|
|
PoolMetaReplica::Corrupt(format!("{kind} tuple payload is truncated: {err}"))
|
|
} else {
|
|
PoolMetaReplica::Incompatible(format!("{kind} tuple payload is not decodable: {err}"))
|
|
}
|
|
}
|
|
|
|
fn parse_pool_meta_uuid(value: &str, field: &str) -> Result<uuid::Uuid> {
|
|
let parsed = uuid::Uuid::parse_str(value)
|
|
.map_err(|err| Error::other(format!("pool metadata corrupt: invalid {field} `{value}`: {err}")))?;
|
|
if parsed.is_nil() || parsed == uuid::Uuid::max() {
|
|
return Err(Error::other(format!("pool metadata corrupt: {field} must be a non-reserved UUID")));
|
|
}
|
|
Ok(parsed)
|
|
}
|
|
|
|
fn pool_meta_generation_revision(
|
|
cluster_id: &str,
|
|
epoch: u64,
|
|
generation: u64,
|
|
transaction_id: &str,
|
|
) -> Result<PoolMetaRevision> {
|
|
if epoch == 0 || generation == 0 {
|
|
return Err(Error::other(
|
|
"pool metadata corrupt: version 3 epoch and generation must both be non-zero",
|
|
));
|
|
}
|
|
Ok(PoolMetaRevision {
|
|
version: POOL_META_GENERATION_VERSION,
|
|
cluster_id: Some(parse_pool_meta_uuid(cluster_id, "cluster identity")?),
|
|
epoch,
|
|
generation,
|
|
transaction_id: Some(parse_pool_meta_uuid(transaction_id, "transaction id")?),
|
|
})
|
|
}
|
|
|
|
fn pool_meta_from_v3_statuses(version: u16, pools: Vec<PersistedPoolStatus>) -> Result<PoolMeta> {
|
|
if !matches!(version, POOL_META_V1_VERSION | POOL_META_VERSION | POOL_META_GENERATION_VERSION) {
|
|
return Err(Error::other(format!(
|
|
"pool metadata corrupt: version 3 previous snapshot has unsupported source version {version}"
|
|
)));
|
|
}
|
|
let meta = PoolMeta {
|
|
version,
|
|
pools: pools.into_iter().map(TryInto::try_into).collect::<Result<Vec<_>>>()?,
|
|
dont_save: false,
|
|
};
|
|
validate_decommission_capacity_model_cohort(&meta)?;
|
|
Ok(meta)
|
|
}
|
|
|
|
fn pool_meta_previous_candidate(value: PersistedPoolMetaV3Previous) -> Result<PoolMetaCommittedCandidate> {
|
|
let revision = match value.version {
|
|
POOL_META_V1_VERSION | POOL_META_VERSION => {
|
|
if value.cluster_id.is_some() || value.epoch != 0 || value.generation != 0 || value.transaction_id.is_some() {
|
|
return Err(Error::other(
|
|
"pool metadata corrupt: legacy previous snapshot carries version 3 revision fields",
|
|
));
|
|
}
|
|
PoolMetaRevision::legacy(value.version)
|
|
}
|
|
POOL_META_GENERATION_VERSION => pool_meta_generation_revision(
|
|
value
|
|
.cluster_id
|
|
.as_deref()
|
|
.ok_or_else(|| Error::other("pool metadata corrupt: version 3 previous snapshot has no cluster identity"))?,
|
|
value.epoch,
|
|
value.generation,
|
|
value
|
|
.transaction_id
|
|
.as_deref()
|
|
.ok_or_else(|| Error::other("pool metadata corrupt: version 3 previous snapshot has no transaction id"))?,
|
|
)?,
|
|
version => {
|
|
return Err(Error::other(format!(
|
|
"pool metadata corrupt: previous snapshot has unsupported version {version}"
|
|
)));
|
|
}
|
|
};
|
|
let meta = pool_meta_from_v3_statuses(value.version, value.pools)?;
|
|
let canonical = if revision.is_generation_protocol() {
|
|
encode_pool_meta_v3_envelope(&meta, revision, true, None)?
|
|
} else {
|
|
meta.encode_config_data_for_v2_gate(true)?
|
|
};
|
|
Ok(PoolMetaCommittedCandidate {
|
|
canonical,
|
|
meta,
|
|
revision,
|
|
})
|
|
}
|
|
|
|
fn decode_pool_meta_v3(data: Vec<u8>) -> PoolMetaReplica {
|
|
let persisted = match rmp_serde::from_slice::<PersistedPoolMetaV3>(&data[4..]) {
|
|
Ok(persisted) => persisted,
|
|
Err(err) => return classify_pool_meta_tuple_decode_error("v3", err),
|
|
};
|
|
if persisted.version != POOL_META_GENERATION_VERSION {
|
|
return PoolMetaReplica::Corrupt(format!(
|
|
"v3 payload has version {}, expected {POOL_META_GENERATION_VERSION}",
|
|
persisted.version
|
|
));
|
|
}
|
|
let revision = match pool_meta_generation_revision(
|
|
&persisted.cluster_id,
|
|
persisted.epoch,
|
|
persisted.generation,
|
|
&persisted.transaction_id,
|
|
) {
|
|
Ok(revision) => revision,
|
|
Err(err) => return PoolMetaReplica::Corrupt(err.to_string()),
|
|
};
|
|
let meta = match pool_meta_from_v3_statuses(POOL_META_GENERATION_VERSION, persisted.pools) {
|
|
Ok(meta) => meta,
|
|
Err(err) => return PoolMetaReplica::Corrupt(err.to_string()),
|
|
};
|
|
let previous = match persisted.previous.map(pool_meta_previous_candidate).transpose() {
|
|
Ok(previous) => previous,
|
|
Err(err) => return PoolMetaReplica::Corrupt(err.to_string()),
|
|
};
|
|
if persisted.committed && previous.is_some() {
|
|
return PoolMetaReplica::Corrupt("committed v3 payload unexpectedly retains a previous snapshot".to_string());
|
|
}
|
|
if !persisted.committed {
|
|
match previous.as_ref() {
|
|
Some(previous) if previous.revision.is_generation_protocol() => {
|
|
if previous.revision.cluster_id != revision.cluster_id
|
|
|| previous.revision.epoch != revision.epoch
|
|
|| previous.revision.generation.checked_add(1) != Some(revision.generation)
|
|
{
|
|
return PoolMetaReplica::Corrupt(
|
|
"pending v3 payload does not advance exactly one generation from its previous snapshot".to_string(),
|
|
);
|
|
}
|
|
}
|
|
Some(_) if revision.generation != 1 => {
|
|
return PoolMetaReplica::Corrupt(
|
|
"first v3 generation must be generation 1 when migrating a legacy snapshot".to_string(),
|
|
);
|
|
}
|
|
None if revision.generation != 1 => {
|
|
return PoolMetaReplica::Corrupt(
|
|
"pending v3 payload without a previous snapshot must be the initial generation".to_string(),
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let canonical = match encode_pool_meta_v3_envelope(&meta, revision, true, None) {
|
|
Ok(canonical) => canonical,
|
|
Err(err) => return PoolMetaReplica::Corrupt(err.to_string()),
|
|
};
|
|
PoolMetaReplica::Valid {
|
|
raw: data,
|
|
canonical,
|
|
meta,
|
|
revision,
|
|
committed: persisted.committed,
|
|
previous: previous.map(Box::new),
|
|
}
|
|
}
|
|
|
|
fn decode_pool_meta_replica(data: Vec<u8>) -> PoolMetaReplica {
|
|
if data.len() <= 4 {
|
|
return PoolMetaReplica::Corrupt("metadata payload is empty or truncated".to_string());
|
|
}
|
|
|
|
let format = LittleEndian::read_u16(&data[0..2]);
|
|
if format != POOL_META_FORMAT {
|
|
return PoolMetaReplica::Incompatible(format!("unsupported format {format}"));
|
|
}
|
|
let version = LittleEndian::read_u16(&data[2..4]);
|
|
if !matches!(version, POOL_META_V1_VERSION | POOL_META_VERSION | POOL_META_GENERATION_VERSION) {
|
|
return PoolMetaReplica::Incompatible(format!("unsupported version {version}"));
|
|
}
|
|
if version == POOL_META_GENERATION_VERSION {
|
|
return decode_pool_meta_v3(data);
|
|
}
|
|
|
|
let payload = &data[4..];
|
|
let meta = match (version, rmp::decode::read_array_len(&mut &payload[..])) {
|
|
(POOL_META_VERSION, Ok(2)) => match rmp_serde::from_slice::<PersistedPoolMeta>(payload) {
|
|
Ok(meta) => match PoolMeta::try_from(meta) {
|
|
Ok(meta) => meta,
|
|
Err(err) => return PoolMetaReplica::Corrupt(err.to_string()),
|
|
},
|
|
Err(err) => return classify_pool_meta_tuple_decode_error("current", err),
|
|
},
|
|
(POOL_META_V1_VERSION, Ok(2)) => match rmp_serde::from_slice::<PersistedPoolMetaV1>(payload) {
|
|
Ok(meta) => match PoolMeta::try_from(meta) {
|
|
Ok(meta) => meta,
|
|
Err(err) => return PoolMetaReplica::Corrupt(err.to_string()),
|
|
},
|
|
Err(err) => return classify_pool_meta_tuple_decode_error("v1", err),
|
|
},
|
|
// The older V1 tuple includes the runtime-only `dont_save` flag.
|
|
(POOL_META_V1_VERSION, Ok(3)) => match rmp_serde::from_slice::<LegacyPoolMeta>(payload) {
|
|
Ok(meta) => match PoolMeta::try_from(meta) {
|
|
Ok(meta) => meta,
|
|
Err(err) => return PoolMetaReplica::Corrupt(err.to_string()),
|
|
},
|
|
Err(err) => return classify_pool_meta_tuple_decode_error("legacy v1", err),
|
|
},
|
|
(_, Ok(field_count)) if field_count < 2 => {
|
|
return PoolMetaReplica::Corrupt(format!("pool metadata tuple has only {field_count} fields"));
|
|
}
|
|
(_, Ok(field_count)) => {
|
|
return PoolMetaReplica::Incompatible(format!(
|
|
"pool metadata version {version} tuple has unsupported field count {field_count}"
|
|
));
|
|
}
|
|
(_, Err(_)) => {
|
|
let mut meta = PoolMeta::default();
|
|
if let Err(err) = meta.load_from_config_data(data.clone()) {
|
|
let reason = err.to_string();
|
|
if reason.contains("unknown field") {
|
|
return PoolMetaReplica::Incompatible(format!("current-version payload uses unsupported fields: {reason}"));
|
|
}
|
|
return PoolMetaReplica::Corrupt(reason);
|
|
}
|
|
meta
|
|
}
|
|
};
|
|
|
|
match meta.encode_config_data_for_v2_gate(true) {
|
|
Ok(canonical) => PoolMetaReplica::Valid {
|
|
raw: data,
|
|
canonical,
|
|
meta,
|
|
revision: PoolMetaRevision::legacy(version),
|
|
committed: true,
|
|
previous: None,
|
|
},
|
|
Err(err) => PoolMetaReplica::Corrupt(err.to_string()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn pool_meta_v3_commit_state_for_test(data: Vec<u8>) -> Result<(u64, bool)> {
|
|
match decode_pool_meta_replica(data) {
|
|
PoolMetaReplica::Valid { revision, committed, .. } if revision.is_generation_protocol() => {
|
|
Ok((revision.generation, committed))
|
|
}
|
|
_ => Err(Error::other("test pool metadata is not a valid V3 replica")),
|
|
}
|
|
}
|
|
|
|
async fn read_pool_meta_replica<S>(pool: Arc<S>, no_lock: bool) -> PoolMetaReplicaRead
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let result = if no_lock {
|
|
read_config_no_lock_preserve_empty_with_metadata(pool, POOL_META_NAME)
|
|
.await
|
|
.map(|(data, object_info)| (data, object_info.etag))
|
|
} else {
|
|
read_config_preserve_empty(pool, POOL_META_NAME)
|
|
.await
|
|
.map(|data| (data, None))
|
|
};
|
|
match result {
|
|
Ok((data, etag)) => PoolMetaReplicaRead {
|
|
replica: decode_pool_meta_replica(data),
|
|
cas: etag
|
|
.filter(|etag| !etag.trim().is_empty())
|
|
.map(PoolMetaCasToken::Existing)
|
|
.unwrap_or(PoolMetaCasToken::Unsafe),
|
|
},
|
|
Err(Error::ConfigNotFound) => PoolMetaReplicaRead {
|
|
replica: PoolMetaReplica::Missing,
|
|
cas: PoolMetaCasToken::Missing,
|
|
},
|
|
Err(err) => PoolMetaReplicaRead {
|
|
replica: PoolMetaReplica::Unreadable(err.to_string()),
|
|
cas: PoolMetaCasToken::Unsafe,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn select_pool_meta_replica_reads(reads: Vec<PoolMetaReplicaRead>) -> Result<PoolMetaSelection> {
|
|
if reads.is_empty() {
|
|
return Err(Error::other("pool metadata recovery required: no storage pools available"));
|
|
}
|
|
|
|
let cas_tokens = reads.iter().map(|read| read.cas.clone()).collect();
|
|
let mut committed = Vec::<(usize, Vec<u8>, PoolMetaCommittedCandidate)>::new();
|
|
let mut needs_repair = false;
|
|
let mut repair_write_safe = true;
|
|
let mut missing = 0usize;
|
|
let mut unusable = Vec::new();
|
|
let mut observed_version = POOL_META_V1_VERSION;
|
|
let mut generation_protocol_observed = false;
|
|
let mut generation_identity = None;
|
|
|
|
for (idx, read) in reads.into_iter().enumerate() {
|
|
match read.replica {
|
|
PoolMetaReplica::Missing => {
|
|
missing += 1;
|
|
needs_repair = true;
|
|
}
|
|
PoolMetaReplica::Corrupt(reason) => {
|
|
needs_repair = true;
|
|
unusable.push(format!("pool {idx} is corrupt: {reason}"));
|
|
}
|
|
PoolMetaReplica::Unreadable(reason) => {
|
|
needs_repair = true;
|
|
repair_write_safe = false;
|
|
unusable.push(format!("pool {idx} is unreadable: {reason}"));
|
|
}
|
|
PoolMetaReplica::Incompatible(reason) => {
|
|
return Err(Error::other(format!(
|
|
"pool metadata recovery required: pool {idx} is incompatible ({reason}); upgrade or restore a compatible replica without overwriting it"
|
|
)));
|
|
}
|
|
PoolMetaReplica::Valid {
|
|
raw,
|
|
canonical,
|
|
meta,
|
|
revision,
|
|
committed: is_committed,
|
|
previous,
|
|
} => {
|
|
generation_protocol_observed |= revision.is_generation_protocol();
|
|
if revision.is_generation_protocol() {
|
|
let identity = (
|
|
revision
|
|
.cluster_id
|
|
.expect("validated V3 revision should carry a cluster identity"),
|
|
revision.epoch,
|
|
);
|
|
if generation_identity.is_some_and(|current| current != identity) {
|
|
return Err(Error::other(
|
|
"pool metadata recovery required: V3 replicas disagree on cluster identity or epoch",
|
|
));
|
|
}
|
|
generation_identity = Some(identity);
|
|
}
|
|
if is_committed {
|
|
observed_version = observed_version.max(meta.version);
|
|
committed.push((
|
|
idx,
|
|
raw,
|
|
PoolMetaCommittedCandidate {
|
|
canonical,
|
|
meta,
|
|
revision,
|
|
},
|
|
));
|
|
} else if let Some(previous) = previous {
|
|
observed_version = observed_version.max(previous.meta.version);
|
|
needs_repair = true;
|
|
committed.push((idx, raw, *previous));
|
|
} else {
|
|
needs_repair = true;
|
|
unusable.push(format!("pool {idx} contains an uncommitted initial generation"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if committed.is_empty() && missing > 0 && unusable.is_empty() {
|
|
return Ok(PoolMetaSelection {
|
|
meta: PoolMeta::default(),
|
|
revision: PoolMetaRevision::legacy(0),
|
|
canonical: None,
|
|
replica_state: PoolMetaReplicaState {
|
|
needs_repair: false,
|
|
repair_write_safe: true,
|
|
},
|
|
cas_tokens,
|
|
absent: true,
|
|
generation_protocol_observed,
|
|
generation_identity,
|
|
});
|
|
}
|
|
if committed.is_empty() {
|
|
return Err(Error::other(format!(
|
|
"pool metadata recovery required: no valid committed replica is available ({})",
|
|
unusable.join("; ")
|
|
)));
|
|
}
|
|
|
|
if committed
|
|
.iter()
|
|
.any(|(_, _, candidate)| candidate.revision.is_generation_protocol())
|
|
{
|
|
let highest = committed
|
|
.iter()
|
|
.filter(|(_, _, candidate)| candidate.revision.is_generation_protocol())
|
|
.map(|(_, _, candidate)| candidate.revision.generation)
|
|
.max()
|
|
.ok_or_else(|| Error::other("pool metadata recovery required: no committed V3 generation is available"))?;
|
|
let mut selected: Option<(usize, &PoolMetaCommittedCandidate)> = None;
|
|
for (idx, _, candidate) in &committed {
|
|
if !candidate.revision.is_generation_protocol() || candidate.revision.generation != highest {
|
|
needs_repair = true;
|
|
continue;
|
|
}
|
|
if let Some((selected_idx, selected_candidate)) = selected {
|
|
if selected_candidate.canonical != candidate.canonical
|
|
|| selected_candidate.revision.transaction_id != candidate.revision.transaction_id
|
|
{
|
|
return Err(Error::other(format!(
|
|
"pool metadata recovery required: committed generation {highest} diverges between pools {selected_idx} and {idx}"
|
|
)));
|
|
}
|
|
} else {
|
|
selected = Some((*idx, candidate));
|
|
}
|
|
}
|
|
let (_, selected) = selected
|
|
.ok_or_else(|| Error::other("pool metadata recovery required: highest V3 generation has no valid snapshot"))?;
|
|
for (_, raw, candidate) in &committed {
|
|
needs_repair |= candidate.canonical != selected.canonical || raw != &selected.canonical;
|
|
}
|
|
return Ok(PoolMetaSelection {
|
|
meta: selected.meta.clone(),
|
|
revision: selected.revision,
|
|
canonical: Some(selected.canonical.clone()),
|
|
replica_state: PoolMetaReplicaState {
|
|
needs_repair,
|
|
repair_write_safe,
|
|
},
|
|
cas_tokens,
|
|
absent: false,
|
|
generation_protocol_observed,
|
|
generation_identity,
|
|
});
|
|
}
|
|
|
|
// Legacy V1/V2 has no durable generation. Pool zero remains the commit
|
|
// record; divergent backups without it are ambiguous and fail closed.
|
|
let mut selected: Option<(usize, Vec<u8>, PoolMetaCommittedCandidate)> = None;
|
|
for (idx, raw, candidate) in committed {
|
|
if let Some((selected_idx, selected_raw, selected_candidate)) = selected.as_ref() {
|
|
if selected_candidate.canonical != candidate.canonical {
|
|
if selected_candidate.meta.version == candidate.meta.version && *selected_idx == 0 {
|
|
needs_repair = true;
|
|
} else {
|
|
return Err(Error::other(format!(
|
|
"pool metadata recovery required: valid replicas in pools {selected_idx} and {idx} diverge; restore one matching pool.bin snapshot before restart"
|
|
)));
|
|
}
|
|
} else {
|
|
needs_repair |= selected_raw != &raw;
|
|
}
|
|
} else {
|
|
selected = Some((idx, raw, candidate));
|
|
}
|
|
}
|
|
let (_, _, mut selected) =
|
|
selected.ok_or_else(|| Error::other("pool metadata recovery required: no valid legacy replica is available"))?;
|
|
selected.meta.version = observed_version;
|
|
selected.revision.version = observed_version;
|
|
Ok(PoolMetaSelection {
|
|
meta: selected.meta,
|
|
revision: selected.revision,
|
|
canonical: Some(selected.canonical),
|
|
replica_state: PoolMetaReplicaState {
|
|
needs_repair,
|
|
repair_write_safe,
|
|
},
|
|
cas_tokens,
|
|
absent: false,
|
|
generation_protocol_observed,
|
|
generation_identity,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn select_pool_meta_replica(replicas: Vec<PoolMetaReplica>) -> Result<PoolMetaSelection> {
|
|
select_pool_meta_replica_reads(
|
|
replicas
|
|
.into_iter()
|
|
.map(|replica| PoolMetaReplicaRead {
|
|
replica,
|
|
cas: PoolMetaCasToken::Unsafe,
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
async fn read_pool_meta_replicas<S>(pools: Vec<Arc<S>>, no_lock: bool) -> Vec<PoolMetaReplicaRead>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
join_all(pools.into_iter().map(|pool| read_pool_meta_replica(pool, no_lock))).await
|
|
}
|
|
|
|
fn select_pool_meta_replicas_observing<R>(write_state: &mut PoolMetaWriteState, replicas: Vec<R>) -> Result<PoolMetaSelection>
|
|
where
|
|
R: Into<PoolMetaReplicaRead>,
|
|
{
|
|
let replicas = replicas.into_iter().map(Into::into).collect::<Vec<_>>();
|
|
if replicas
|
|
.iter()
|
|
.any(|replica| matches!(&replica.replica, PoolMetaReplica::Unreadable(_)))
|
|
{
|
|
write_state.block_writes();
|
|
}
|
|
match select_pool_meta_replica_reads(replicas) {
|
|
Ok(selection) => {
|
|
if let Err(err) = write_state.observe_selection(&selection) {
|
|
write_state.block_writes();
|
|
return Err(err);
|
|
}
|
|
Ok(selection)
|
|
}
|
|
Err(err) => {
|
|
write_state.block_writes();
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn load_pool_meta_replicas<S>(pools: Vec<Arc<S>>, no_lock: bool) -> Result<PoolMetaSelection>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
select_pool_meta_replica_reads(read_pool_meta_replicas(pools, no_lock).await)
|
|
}
|
|
|
|
async fn load_pool_meta_replicas_observing<S>(
|
|
pools: Vec<Arc<S>>,
|
|
no_lock: bool,
|
|
write_state: &mut PoolMetaWriteState,
|
|
) -> Result<PoolMetaSelection>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let replicas = read_pool_meta_replicas(pools, no_lock).await;
|
|
select_pool_meta_replicas_observing(write_state, replicas)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedPoolMetaV3 {
|
|
version: u16,
|
|
cluster_id: String,
|
|
epoch: u64,
|
|
generation: u64,
|
|
transaction_id: String,
|
|
committed: bool,
|
|
pools: Vec<PersistedPoolStatus>,
|
|
previous: Option<PersistedPoolMetaV3Previous>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedPoolMetaV3Previous {
|
|
version: u16,
|
|
cluster_id: Option<String>,
|
|
epoch: u64,
|
|
generation: u64,
|
|
transaction_id: Option<String>,
|
|
pools: Vec<PersistedPoolStatus>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedPoolMetaIdentity {
|
|
version: u16,
|
|
cluster_id: uuid::Uuid,
|
|
epoch: u64,
|
|
initialized: bool,
|
|
fresh_bootstrap_nonce: Option<uuid::Uuid>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum PoolMetaIdentityReplica {
|
|
Missing,
|
|
Valid(PersistedPoolMetaIdentity),
|
|
Corrupt(String),
|
|
Incompatible(String),
|
|
Unreadable(String),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PoolMetaIdentityRead {
|
|
replica: PoolMetaIdentityReplica,
|
|
cas: PoolMetaCasToken,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PoolMetaIdentitySelection {
|
|
identity: Option<PersistedPoolMetaIdentity>,
|
|
needs_repair: bool,
|
|
repair_write_safe: bool,
|
|
cas_tokens: Vec<PoolMetaCasToken>,
|
|
}
|
|
|
|
fn encode_pool_meta_identity(identity: PersistedPoolMetaIdentity) -> Result<Vec<u8>> {
|
|
let mut data = Vec::new();
|
|
data.write_u16::<LittleEndian>(POOL_META_IDENTITY_FORMAT)?;
|
|
data.write_u16::<LittleEndian>(POOL_META_IDENTITY_VERSION)?;
|
|
identity.serialize(&mut Serializer::new(&mut data))?;
|
|
Ok(data)
|
|
}
|
|
|
|
fn decode_pool_meta_identity(data: &[u8]) -> PoolMetaIdentityReplica {
|
|
if data.len() <= 4 {
|
|
return PoolMetaIdentityReplica::Corrupt("identity payload is empty or truncated".to_string());
|
|
}
|
|
let format = LittleEndian::read_u16(&data[0..2]);
|
|
let version = LittleEndian::read_u16(&data[2..4]);
|
|
if format != POOL_META_IDENTITY_FORMAT || version != POOL_META_IDENTITY_VERSION {
|
|
return PoolMetaIdentityReplica::Incompatible(format!("unsupported identity format {format} version {version}"));
|
|
}
|
|
let identity = match rmp_serde::from_slice::<PersistedPoolMetaIdentity>(&data[4..]) {
|
|
Ok(identity) => identity,
|
|
Err(err) => return PoolMetaIdentityReplica::Corrupt(format!("identity payload is not decodable: {err}")),
|
|
};
|
|
let invalid_bootstrap_nonce = identity
|
|
.fresh_bootstrap_nonce
|
|
.is_some_and(|nonce| nonce.is_nil() || nonce == uuid::Uuid::max());
|
|
if identity.version != POOL_META_IDENTITY_VERSION
|
|
|| identity.cluster_id.is_nil()
|
|
|| identity.cluster_id == uuid::Uuid::max()
|
|
|| identity.epoch == 0
|
|
|| invalid_bootstrap_nonce
|
|
|| identity.initialized == identity.fresh_bootstrap_nonce.is_some()
|
|
{
|
|
return PoolMetaIdentityReplica::Corrupt(
|
|
"identity payload contains an invalid version, UUID, epoch, or fresh-bootstrap proof".to_string(),
|
|
);
|
|
}
|
|
PoolMetaIdentityReplica::Valid(identity)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn pool_meta_identity_initialized_for_test(data: &[u8]) -> Result<bool> {
|
|
match decode_pool_meta_identity(data) {
|
|
PoolMetaIdentityReplica::Valid(identity) => Ok(identity.initialized),
|
|
_ => Err(Error::other("test pool metadata identity is not valid")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn initialized_pool_meta_identity_for_test(cluster_id: uuid::Uuid, epoch: u64) -> Result<Vec<u8>> {
|
|
encode_pool_meta_identity(PersistedPoolMetaIdentity {
|
|
version: POOL_META_IDENTITY_VERSION,
|
|
cluster_id,
|
|
epoch,
|
|
initialized: true,
|
|
fresh_bootstrap_nonce: None,
|
|
})
|
|
}
|
|
|
|
async fn read_pool_meta_identity_replica<S>(pool: Arc<S>) -> PoolMetaIdentityRead
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
match read_config_no_lock_preserve_empty_with_metadata(pool, POOL_META_IDENTITY_NAME).await {
|
|
Ok((data, object_info)) => PoolMetaIdentityRead {
|
|
replica: decode_pool_meta_identity(&data),
|
|
cas: object_info
|
|
.etag
|
|
.filter(|etag| !etag.trim().is_empty())
|
|
.map(PoolMetaCasToken::Existing)
|
|
.unwrap_or(PoolMetaCasToken::Unsafe),
|
|
},
|
|
Err(Error::ConfigNotFound) => PoolMetaIdentityRead {
|
|
replica: PoolMetaIdentityReplica::Missing,
|
|
cas: PoolMetaCasToken::Missing,
|
|
},
|
|
Err(err) => PoolMetaIdentityRead {
|
|
replica: PoolMetaIdentityReplica::Unreadable(err.to_string()),
|
|
cas: PoolMetaCasToken::Unsafe,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn select_pool_meta_identity(
|
|
reads: Vec<PoolMetaIdentityRead>,
|
|
expected_cluster_id: uuid::Uuid,
|
|
) -> Result<PoolMetaIdentitySelection> {
|
|
let cas_tokens = reads.iter().map(|read| read.cas.clone()).collect();
|
|
let mut selected: Option<PersistedPoolMetaIdentity> = None;
|
|
let mut needs_repair = false;
|
|
let mut repair_write_safe = true;
|
|
let mut unusable = Vec::new();
|
|
for (idx, read) in reads.into_iter().enumerate() {
|
|
match read.replica {
|
|
PoolMetaIdentityReplica::Missing => needs_repair = true,
|
|
PoolMetaIdentityReplica::Corrupt(reason) => {
|
|
needs_repair = true;
|
|
unusable.push(format!("pool {idx} identity is corrupt: {reason}"));
|
|
}
|
|
PoolMetaIdentityReplica::Unreadable(reason) => {
|
|
needs_repair = true;
|
|
repair_write_safe = false;
|
|
unusable.push(format!("pool {idx} identity is unreadable: {reason}"));
|
|
}
|
|
PoolMetaIdentityReplica::Incompatible(reason) => {
|
|
return Err(Error::other(format!("pool metadata incompatible: pool {idx} identity uses {reason}")));
|
|
}
|
|
PoolMetaIdentityReplica::Valid(identity) => {
|
|
if identity.cluster_id != expected_cluster_id {
|
|
return Err(Error::other(format!(
|
|
"pool metadata incompatible: pool {idx} identity {} does not match deployment {expected_cluster_id}",
|
|
identity.cluster_id
|
|
)));
|
|
}
|
|
if let Some(current) = selected {
|
|
if current.cluster_id != identity.cluster_id || current.epoch != identity.epoch {
|
|
return Err(Error::other(
|
|
"pool metadata recovery required: identity replicas disagree on cluster identity or epoch",
|
|
));
|
|
}
|
|
if current.initialized != identity.initialized {
|
|
needs_repair = true;
|
|
selected = Some(if current.initialized { current } else { identity });
|
|
} else if !current.initialized && current.fresh_bootstrap_nonce != identity.fresh_bootstrap_nonce {
|
|
return Err(Error::other(
|
|
"pool metadata recovery required: pending identity replicas disagree on fresh-bootstrap proof",
|
|
));
|
|
}
|
|
} else {
|
|
selected = Some(identity);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if selected.is_none() && !unusable.is_empty() {
|
|
return Err(Error::other(format!(
|
|
"pool metadata recovery required: no valid cluster identity replica is available ({})",
|
|
unusable.join("; ")
|
|
)));
|
|
}
|
|
Ok(PoolMetaIdentitySelection {
|
|
identity: selected,
|
|
needs_repair,
|
|
repair_write_safe,
|
|
cas_tokens,
|
|
})
|
|
}
|
|
|
|
async fn load_pool_meta_identity<S>(pools: Vec<Arc<S>>, expected_cluster_id: uuid::Uuid) -> Result<PoolMetaIdentitySelection>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let reads = join_all(pools.into_iter().map(read_pool_meta_identity_replica)).await;
|
|
select_pool_meta_identity(reads, expected_cluster_id)
|
|
}
|
|
|
|
async fn load_pool_meta_identity_selection_observing<S>(
|
|
pools: Vec<Arc<S>>,
|
|
write_state: &mut PoolMetaWriteState,
|
|
expected_cluster_id: uuid::Uuid,
|
|
) -> Result<PoolMetaIdentitySelection>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let selection = match load_pool_meta_identity(pools, expected_cluster_id).await {
|
|
Ok(selection) => selection,
|
|
Err(err) => {
|
|
write_state.block_writes();
|
|
return Err(err);
|
|
}
|
|
};
|
|
if let Err(err) = write_state.observe_identity(&selection) {
|
|
write_state.block_writes();
|
|
return Err(err);
|
|
}
|
|
Ok(selection)
|
|
}
|
|
|
|
fn persisted_pool_meta_v3_previous(candidate: &PoolMetaCommittedCandidate) -> PersistedPoolMetaV3Previous {
|
|
PersistedPoolMetaV3Previous {
|
|
version: candidate.revision.version,
|
|
cluster_id: candidate.revision.cluster_id.map(|id| id.to_string()),
|
|
epoch: candidate.revision.epoch,
|
|
generation: candidate.revision.generation,
|
|
transaction_id: candidate.revision.transaction_id.map(|id| id.to_string()),
|
|
pools: candidate.meta.pools.iter().map(Into::into).collect(),
|
|
}
|
|
}
|
|
|
|
fn encode_pool_meta_v3_envelope(
|
|
meta: &PoolMeta,
|
|
revision: PoolMetaRevision,
|
|
committed: bool,
|
|
previous: Option<&PoolMetaCommittedCandidate>,
|
|
) -> Result<Vec<u8>> {
|
|
if meta.dont_save {
|
|
return Ok(Vec::new());
|
|
}
|
|
let cluster_id = revision
|
|
.cluster_id
|
|
.ok_or_else(|| Error::other("pool metadata V3 save failed: cluster identity is not initialized"))?;
|
|
let transaction_id = revision
|
|
.transaction_id
|
|
.ok_or_else(|| Error::other("pool metadata V3 save failed: transaction id is not initialized"))?;
|
|
if revision.version != POOL_META_GENERATION_VERSION || revision.epoch == 0 || revision.generation == 0 {
|
|
return Err(Error::other("pool metadata V3 save failed: invalid durable revision"));
|
|
}
|
|
let persisted = PersistedPoolMetaV3 {
|
|
version: POOL_META_GENERATION_VERSION,
|
|
cluster_id: cluster_id.to_string(),
|
|
epoch: revision.epoch,
|
|
generation: revision.generation,
|
|
transaction_id: transaction_id.to_string(),
|
|
committed,
|
|
pools: meta.pools.iter().map(Into::into).collect(),
|
|
previous: previous.map(persisted_pool_meta_v3_previous),
|
|
};
|
|
let mut data = Vec::new();
|
|
data.write_u16::<LittleEndian>(POOL_META_FORMAT)?;
|
|
data.write_u16::<LittleEndian>(POOL_META_GENERATION_VERSION)?;
|
|
persisted.serialize(&mut Serializer::new(&mut data))?;
|
|
Ok(data)
|
|
}
|
|
|
|
enum PoolMetaPersistenceFence<'a> {
|
|
Distributed(Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>),
|
|
Activation(&'a PoolRebalanceActivationFence),
|
|
}
|
|
|
|
impl PoolMetaPersistenceFence<'_> {
|
|
fn ensure_held(&self) -> Result<()> {
|
|
match self {
|
|
Self::Distributed(Some(signal)) if signal.is_lost() => {
|
|
Err(Error::other("pool metadata distributed fence was lost before a replica write"))
|
|
}
|
|
Self::Activation(fence) => fence.ensure_held(),
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn add_to_options(&self, opts: &mut ObjectOptions) {
|
|
match self {
|
|
Self::Distributed(Some(signal)) => opts.add_namespace_lock_lost_signal(Arc::clone(signal)),
|
|
Self::Activation(fence) => fence.add_namespace_lock_fence(opts),
|
|
Self::Distributed(None) => {}
|
|
}
|
|
}
|
|
|
|
fn is_activation(&self) -> bool {
|
|
matches!(self, Self::Activation(_))
|
|
}
|
|
}
|
|
|
|
fn pool_meta_cas_preconditions(token: &PoolMetaCasToken, object: &str) -> Result<HTTPPreconditions> {
|
|
match token {
|
|
PoolMetaCasToken::Missing => Ok(HTTPPreconditions {
|
|
if_none_match: Some("*".to_string()),
|
|
..Default::default()
|
|
}),
|
|
PoolMetaCasToken::Existing(etag) => Ok(HTTPPreconditions {
|
|
if_match: Some(etag.clone()),
|
|
..Default::default()
|
|
}),
|
|
PoolMetaCasToken::Unsafe => Err(Error::other(format!(
|
|
"pool metadata recovery required: {object} replica has no safe conditional-write revision"
|
|
))),
|
|
}
|
|
}
|
|
|
|
async fn save_pool_meta_object_cas<S>(
|
|
pool: Arc<S>,
|
|
object: &str,
|
|
data: Vec<u8>,
|
|
token: &PoolMetaCasToken,
|
|
fence: &PoolMetaPersistenceFence<'_>,
|
|
phase: &'static str,
|
|
) -> Result<crate::object_api::ObjectInfo>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
fence.ensure_held()?;
|
|
let mut opts = ObjectOptions {
|
|
max_parity: true,
|
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
|
no_lock: true,
|
|
http_preconditions: Some(pool_meta_cas_preconditions(token, object)?),
|
|
..Default::default()
|
|
};
|
|
fence.add_to_options(&mut opts);
|
|
let result = save_config_with_opts_and_metadata(pool, object, data, &opts).await;
|
|
if matches!(&result, Err(Error::PreconditionFailed)) {
|
|
record_pool_meta_stale_write_rejection(phase);
|
|
}
|
|
let object_info = result?;
|
|
fence.ensure_held()?;
|
|
Ok(object_info)
|
|
}
|
|
|
|
async fn persist_pool_meta_identity<S>(
|
|
pools: Vec<Arc<S>>,
|
|
write_state: &mut PoolMetaWriteState,
|
|
initialized: bool,
|
|
fence: &PoolMetaPersistenceFence<'_>,
|
|
) -> Result<()>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let Some(cluster_id) = write_state.expected_cluster_id else {
|
|
return Ok(());
|
|
};
|
|
for attempt in 0..POOL_META_CAS_MAX_ATTEMPTS {
|
|
let selection = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
|
if !selection.repair_write_safe {
|
|
write_state.block_writes();
|
|
return Err(Error::other(
|
|
"pool metadata recovery required: cluster identity has an unreadable replica",
|
|
));
|
|
}
|
|
let identity = match selection.identity {
|
|
Some(identity) if identity.initialized || initialized => PersistedPoolMetaIdentity {
|
|
initialized: true,
|
|
fresh_bootstrap_nonce: None,
|
|
..identity
|
|
},
|
|
Some(identity) => identity,
|
|
None if !initialized && !write_state.bootstrap_identity_proven() => {
|
|
write_state.block_writes();
|
|
return Err(Error::other(
|
|
"pool metadata recovery required: cannot create a pending cluster identity without verified fresh-bootstrap proof or legacy-adoption proof",
|
|
));
|
|
}
|
|
None => PersistedPoolMetaIdentity {
|
|
version: POOL_META_IDENTITY_VERSION,
|
|
cluster_id,
|
|
epoch: write_state.cluster_epoch.unwrap_or(POOL_META_INITIAL_EPOCH),
|
|
initialized,
|
|
fresh_bootstrap_nonce: (!initialized).then(uuid::Uuid::new_v4),
|
|
},
|
|
};
|
|
if selection.identity == Some(identity) && !selection.needs_repair {
|
|
return Ok(());
|
|
}
|
|
let data = encode_pool_meta_identity(identity)?;
|
|
let mut conflict = false;
|
|
for (pool, token) in pools.iter().cloned().zip(&selection.cas_tokens) {
|
|
match save_pool_meta_object_cas(pool, POOL_META_IDENTITY_NAME, data.clone(), token, fence, "identity_cas").await {
|
|
Ok(_) => {}
|
|
Err(Error::PreconditionFailed) => {
|
|
conflict = true;
|
|
break;
|
|
}
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
if conflict {
|
|
if attempt + 1 < POOL_META_CAS_MAX_ATTEMPTS {
|
|
continue;
|
|
}
|
|
return Err(Error::PreconditionFailed);
|
|
}
|
|
let confirmed = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
|
if confirmed.identity == Some(identity) && !confirmed.needs_repair {
|
|
return Ok(());
|
|
}
|
|
}
|
|
Err(Error::PreconditionFailed)
|
|
}
|
|
|
|
pub(crate) async fn load_pool_meta_identity_observing<S>(pools: Vec<Arc<S>>, write_state: &mut PoolMetaWriteState) -> Result<()>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let Some(cluster_id) = write_state.expected_cluster_id else {
|
|
return Ok(());
|
|
};
|
|
load_pool_meta_identity_selection_observing(pools, write_state, cluster_id)
|
|
.await
|
|
.map(|_| ())
|
|
}
|
|
|
|
pub(crate) async fn persist_pool_meta_identity_for_startup<S>(
|
|
pools: Vec<Arc<S>>,
|
|
write_state: &mut PoolMetaWriteState,
|
|
initialized: bool,
|
|
) -> Result<()>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
persist_pool_meta_identity(pools, write_state, initialized, &PoolMetaPersistenceFence::Distributed(None)).await
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedPoolMeta {
|
|
pub version: u16,
|
|
pub pools: Vec<PersistedPoolStatus>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedPoolStatus {
|
|
#[serde(rename = "id")]
|
|
pub id: usize,
|
|
#[serde(rename = "cmdline")]
|
|
pub cmd_line: String,
|
|
#[serde(rename = "lastUpdate", with = "time::serde::rfc3339")]
|
|
pub last_update: OffsetDateTime,
|
|
#[serde(rename = "decommissionInfo")]
|
|
pub decommission: Option<PersistedPoolDecommissionInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedPoolDecommissionInfo {
|
|
#[serde(rename = "startTime", with = "time::serde::rfc3339::option")]
|
|
pub start_time: Option<OffsetDateTime>,
|
|
#[serde(rename = "startSize")]
|
|
pub start_size: usize,
|
|
#[serde(rename = "totalSize")]
|
|
pub total_size: usize,
|
|
#[serde(rename = "currentSize")]
|
|
pub current_size: usize,
|
|
#[serde(rename = "complete")]
|
|
pub complete: bool,
|
|
#[serde(rename = "failed")]
|
|
pub failed: bool,
|
|
#[serde(rename = "canceled")]
|
|
pub canceled: bool,
|
|
#[serde(rename = "queued", default)]
|
|
pub queued: bool,
|
|
#[serde(rename = "queuedBuckets", default)]
|
|
pub queued_buckets: Vec<String>,
|
|
#[serde(rename = "decommissionedBuckets", default)]
|
|
pub decommissioned_buckets: Vec<String>,
|
|
#[serde(rename = "bucket", default)]
|
|
pub bucket: String,
|
|
#[serde(rename = "prefix", default)]
|
|
pub prefix: String,
|
|
#[serde(rename = "object", default)]
|
|
pub object: String,
|
|
#[serde(rename = "objectsDecommissioned")]
|
|
pub items_decommissioned: usize,
|
|
#[serde(rename = "objectsDecommissionedFailed")]
|
|
pub items_decommission_failed: usize,
|
|
#[serde(rename = "bytesDecommissioned")]
|
|
pub bytes_done: usize,
|
|
#[serde(rename = "bytesDecommissionedFailed")]
|
|
pub bytes_failed: usize,
|
|
#[serde(rename = "terminalReloadAttemptAt", with = "time::serde::rfc3339::option", default)]
|
|
pub terminal_reload_attempt_at: Option<OffsetDateTime>,
|
|
#[serde(rename = "terminalReloadFailures", default)]
|
|
pub terminal_reload_failures: Vec<String>,
|
|
#[serde(rename = "unresolvedEntries", default)]
|
|
pub unresolved_entries: Vec<DecommissionUnresolvedEntry>,
|
|
#[serde(rename = "capacityReservation", default)]
|
|
pub capacity_reservation: Option<DecommissionCapacityReservation>,
|
|
#[serde(rename = "capacityBlockedReason", default)]
|
|
pub capacity_blocked_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedPoolMetaV1 {
|
|
pub version: u16,
|
|
pub pools: Vec<PersistedPoolStatusV1>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedPoolStatusV1 {
|
|
#[serde(rename = "id")]
|
|
pub id: usize,
|
|
#[serde(rename = "cmdline")]
|
|
pub cmd_line: String,
|
|
#[serde(rename = "lastUpdate", with = "time::serde::rfc3339")]
|
|
pub last_update: OffsetDateTime,
|
|
#[serde(rename = "decommissionInfo")]
|
|
pub decommission: Option<PersistedPoolDecommissionInfoV1>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PersistedPoolDecommissionInfoV1 {
|
|
#[serde(rename = "startTime", with = "time::serde::rfc3339::option")]
|
|
pub start_time: Option<OffsetDateTime>,
|
|
#[serde(rename = "startSize")]
|
|
pub start_size: usize,
|
|
#[serde(rename = "totalSize")]
|
|
pub total_size: usize,
|
|
#[serde(rename = "currentSize")]
|
|
pub current_size: usize,
|
|
#[serde(rename = "complete")]
|
|
pub complete: bool,
|
|
#[serde(rename = "failed")]
|
|
pub failed: bool,
|
|
#[serde(rename = "canceled")]
|
|
pub canceled: bool,
|
|
#[serde(rename = "queued", default)]
|
|
pub queued: bool,
|
|
#[serde(rename = "queuedBuckets", default)]
|
|
pub queued_buckets: Vec<String>,
|
|
#[serde(rename = "decommissionedBuckets", default)]
|
|
pub decommissioned_buckets: Vec<String>,
|
|
#[serde(rename = "bucket", default)]
|
|
pub bucket: String,
|
|
#[serde(rename = "prefix", default)]
|
|
pub prefix: String,
|
|
#[serde(rename = "object", default)]
|
|
pub object: String,
|
|
#[serde(rename = "objectsDecommissioned")]
|
|
pub items_decommissioned: usize,
|
|
#[serde(rename = "objectsDecommissionedFailed")]
|
|
pub items_decommission_failed: usize,
|
|
#[serde(rename = "bytesDecommissioned")]
|
|
pub bytes_done: usize,
|
|
#[serde(rename = "bytesDecommissionedFailed")]
|
|
pub bytes_failed: usize,
|
|
#[serde(rename = "terminalReloadAttemptAt", with = "time::serde::rfc3339::option", default)]
|
|
pub terminal_reload_attempt_at: Option<OffsetDateTime>,
|
|
#[serde(rename = "terminalReloadFailures", default)]
|
|
pub terminal_reload_failures: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct LegacyPoolMeta {
|
|
pub version: u16,
|
|
pub pools: Vec<LegacyPoolStatus>,
|
|
pub dont_save: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct LegacyPoolStatus {
|
|
#[serde(rename = "id")]
|
|
pub id: usize,
|
|
#[serde(rename = "cmdline")]
|
|
pub cmd_line: String,
|
|
#[serde(rename = "lastUpdate", with = "time::serde::rfc3339")]
|
|
pub last_update: OffsetDateTime,
|
|
#[serde(rename = "decommissionInfo")]
|
|
pub decommission: Option<LegacyPoolDecommissionInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct LegacyPoolDecommissionInfo {
|
|
#[serde(rename = "startTime", with = "time::serde::rfc3339::option")]
|
|
pub start_time: Option<OffsetDateTime>,
|
|
#[serde(rename = "startSize")]
|
|
pub start_size: usize,
|
|
#[serde(rename = "totalSize")]
|
|
pub total_size: usize,
|
|
#[serde(rename = "currentSize")]
|
|
pub current_size: usize,
|
|
#[serde(rename = "complete")]
|
|
pub complete: bool,
|
|
#[serde(rename = "failed")]
|
|
pub failed: bool,
|
|
#[serde(rename = "canceled")]
|
|
pub canceled: bool,
|
|
#[serde(rename = "objectsDecommissioned")]
|
|
pub items_decommissioned: usize,
|
|
#[serde(rename = "objectsDecommissionedFailed")]
|
|
pub items_decommission_failed: usize,
|
|
#[serde(rename = "bytesDecommissioned")]
|
|
pub bytes_done: usize,
|
|
#[serde(rename = "bytesDecommissionedFailed")]
|
|
pub bytes_failed: usize,
|
|
}
|
|
|
|
fn ensure_pool_meta_payload_version(actual: u16, expected: u16, kind: &str) -> Result<()> {
|
|
if actual == expected {
|
|
return Ok(());
|
|
}
|
|
Err(Error::other(format!(
|
|
"pool metadata {kind} payload has version {actual}, expected {expected}"
|
|
)))
|
|
}
|
|
|
|
impl TryFrom<PersistedPoolMeta> for PoolMeta {
|
|
type Error = Error;
|
|
|
|
fn try_from(value: PersistedPoolMeta) -> Result<Self> {
|
|
ensure_pool_meta_payload_version(value.version, POOL_META_VERSION, "current")?;
|
|
let meta = Self {
|
|
version: POOL_META_VERSION,
|
|
pools: value.pools.into_iter().map(TryInto::try_into).collect::<Result<Vec<_>>>()?,
|
|
dont_save: false,
|
|
};
|
|
validate_decommission_capacity_model_cohort(&meta)?;
|
|
Ok(meta)
|
|
}
|
|
}
|
|
|
|
impl TryFrom<PersistedPoolMetaV1> for PoolMeta {
|
|
type Error = Error;
|
|
|
|
fn try_from(value: PersistedPoolMetaV1) -> Result<Self> {
|
|
ensure_pool_meta_payload_version(value.version, POOL_META_V1_VERSION, "v1")?;
|
|
Ok(Self {
|
|
version: POOL_META_V1_VERSION,
|
|
pools: value.pools.into_iter().map(TryInto::try_into).collect::<Result<Vec<_>>>()?,
|
|
dont_save: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<LegacyPoolMeta> for PoolMeta {
|
|
type Error = Error;
|
|
|
|
fn try_from(value: LegacyPoolMeta) -> Result<Self> {
|
|
let LegacyPoolMeta {
|
|
version,
|
|
pools,
|
|
dont_save: _,
|
|
} = value;
|
|
ensure_pool_meta_payload_version(version, POOL_META_V1_VERSION, "legacy v1")?;
|
|
Ok(Self {
|
|
version: POOL_META_V1_VERSION,
|
|
pools: pools.into_iter().map(TryInto::try_into).collect::<Result<Vec<_>>>()?,
|
|
dont_save: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<PersistedPoolStatus> for PoolStatus {
|
|
type Error = Error;
|
|
|
|
fn try_from(value: PersistedPoolStatus) -> Result<Self> {
|
|
let decommission = value.decommission.map(TryInto::try_into).transpose()?;
|
|
if decommission
|
|
.as_ref()
|
|
.and_then(|info: &PoolDecommissionInfo| info.capacity_reservation.as_ref())
|
|
.is_some_and(|reservation| reservation.source_pool_index != value.id)
|
|
{
|
|
return Err(Error::other(
|
|
"pool metadata load failed: decommission capacity reservation source pool does not match its owner",
|
|
));
|
|
}
|
|
Ok(Self {
|
|
id: value.id,
|
|
cmd_line: value.cmd_line,
|
|
last_update: value.last_update,
|
|
decommission,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<PersistedPoolStatusV1> for PoolStatus {
|
|
type Error = Error;
|
|
|
|
fn try_from(value: PersistedPoolStatusV1) -> Result<Self> {
|
|
Ok(Self {
|
|
id: value.id,
|
|
cmd_line: value.cmd_line,
|
|
last_update: value.last_update,
|
|
decommission: value.decommission.map(TryInto::try_into).transpose()?,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<LegacyPoolStatus> for PoolStatus {
|
|
type Error = Error;
|
|
|
|
fn try_from(value: LegacyPoolStatus) -> Result<Self> {
|
|
Ok(Self {
|
|
id: value.id,
|
|
cmd_line: value.cmd_line,
|
|
last_update: value.last_update,
|
|
decommission: value.decommission.map(TryInto::try_into).transpose()?,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<PersistedPoolDecommissionInfo> for PoolDecommissionInfo {
|
|
type Error = Error;
|
|
|
|
fn try_from(value: PersistedPoolDecommissionInfo) -> Result<Self> {
|
|
validate_decommission_terminal_state(value.complete, value.failed, value.canceled)?;
|
|
validate_decommission_capacity_reservation(value.capacity_reservation.as_ref())?;
|
|
Ok(Self {
|
|
start_time: value.start_time,
|
|
start_size: value.start_size,
|
|
total_size: value.total_size,
|
|
current_size: value.current_size,
|
|
complete: value.complete,
|
|
failed: value.failed,
|
|
canceled: value.canceled,
|
|
queued: value.queued,
|
|
queued_buckets: value.queued_buckets,
|
|
decommissioned_buckets: value.decommissioned_buckets,
|
|
bucket: value.bucket,
|
|
prefix: value.prefix,
|
|
object: value.object,
|
|
stage: String::new(),
|
|
items_decommissioned: value.items_decommissioned,
|
|
items_decommission_failed: value.items_decommission_failed,
|
|
bytes_done: value.bytes_done,
|
|
bytes_failed: value.bytes_failed,
|
|
terminal_reload_attempt_at: value.terminal_reload_attempt_at,
|
|
terminal_reload_failures: value.terminal_reload_failures,
|
|
capacity_reservation: value.capacity_reservation,
|
|
capacity_blocked_reason: value.capacity_blocked_reason,
|
|
unresolved_entries: value.unresolved_entries,
|
|
progress_save_item_baseline: value.items_decommissioned.saturating_add(value.items_decommission_failed),
|
|
progress_save_retry_after: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<PersistedPoolDecommissionInfoV1> for PoolDecommissionInfo {
|
|
type Error = Error;
|
|
|
|
fn try_from(value: PersistedPoolDecommissionInfoV1) -> Result<Self> {
|
|
validate_decommission_terminal_state(value.complete, value.failed, value.canceled)?;
|
|
Ok(Self {
|
|
start_time: value.start_time,
|
|
start_size: value.start_size,
|
|
total_size: value.total_size,
|
|
current_size: value.current_size,
|
|
complete: value.complete,
|
|
failed: value.failed,
|
|
canceled: value.canceled,
|
|
queued: value.queued,
|
|
queued_buckets: value.queued_buckets,
|
|
decommissioned_buckets: value.decommissioned_buckets,
|
|
bucket: value.bucket,
|
|
prefix: value.prefix,
|
|
object: value.object,
|
|
stage: String::new(),
|
|
items_decommissioned: value.items_decommissioned,
|
|
items_decommission_failed: value.items_decommission_failed,
|
|
bytes_done: value.bytes_done,
|
|
bytes_failed: value.bytes_failed,
|
|
terminal_reload_attempt_at: value.terminal_reload_attempt_at,
|
|
terminal_reload_failures: value.terminal_reload_failures,
|
|
capacity_reservation: None,
|
|
capacity_blocked_reason: None,
|
|
unresolved_entries: Vec::new(),
|
|
progress_save_item_baseline: value.items_decommissioned.saturating_add(value.items_decommission_failed),
|
|
progress_save_retry_after: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TryFrom<LegacyPoolDecommissionInfo> for PoolDecommissionInfo {
|
|
type Error = Error;
|
|
|
|
fn try_from(value: LegacyPoolDecommissionInfo) -> Result<Self> {
|
|
validate_decommission_terminal_state(value.complete, value.failed, value.canceled)?;
|
|
Ok(Self {
|
|
start_time: value.start_time,
|
|
start_size: value.start_size,
|
|
total_size: value.total_size,
|
|
current_size: value.current_size,
|
|
complete: value.complete,
|
|
failed: value.failed,
|
|
canceled: value.canceled,
|
|
queued: false,
|
|
queued_buckets: Vec::new(),
|
|
decommissioned_buckets: Vec::new(),
|
|
bucket: String::new(),
|
|
prefix: String::new(),
|
|
object: String::new(),
|
|
stage: String::new(),
|
|
items_decommissioned: value.items_decommissioned,
|
|
items_decommission_failed: value.items_decommission_failed,
|
|
bytes_done: value.bytes_done,
|
|
bytes_failed: value.bytes_failed,
|
|
terminal_reload_attempt_at: None,
|
|
terminal_reload_failures: Vec::new(),
|
|
capacity_reservation: None,
|
|
capacity_blocked_reason: None,
|
|
unresolved_entries: Vec::new(),
|
|
progress_save_item_baseline: value.items_decommissioned.saturating_add(value.items_decommission_failed),
|
|
progress_save_retry_after: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl From<&PoolMeta> for PersistedPoolMeta {
|
|
fn from(value: &PoolMeta) -> Self {
|
|
Self {
|
|
version: POOL_META_VERSION,
|
|
pools: value.pools.iter().map(Into::into).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&PoolMeta> for PersistedPoolMetaV1 {
|
|
fn from(value: &PoolMeta) -> Self {
|
|
Self {
|
|
version: POOL_META_V1_VERSION,
|
|
pools: value.pools.iter().map(Into::into).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&PoolStatus> for PersistedPoolStatus {
|
|
fn from(value: &PoolStatus) -> Self {
|
|
Self {
|
|
id: value.id,
|
|
cmd_line: value.cmd_line.clone(),
|
|
last_update: value.last_update,
|
|
decommission: value.decommission.as_ref().map(Into::into),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&PoolStatus> for PersistedPoolStatusV1 {
|
|
fn from(value: &PoolStatus) -> Self {
|
|
Self {
|
|
id: value.id,
|
|
cmd_line: value.cmd_line.clone(),
|
|
last_update: value.last_update,
|
|
decommission: value.decommission.as_ref().map(Into::into),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&PoolDecommissionInfo> for PersistedPoolDecommissionInfo {
|
|
fn from(value: &PoolDecommissionInfo) -> Self {
|
|
Self {
|
|
start_time: value.start_time,
|
|
start_size: value.start_size,
|
|
total_size: value.total_size,
|
|
current_size: value.current_size,
|
|
complete: value.complete,
|
|
failed: value.failed,
|
|
canceled: value.canceled,
|
|
queued: value.queued,
|
|
queued_buckets: value.queued_buckets.clone(),
|
|
decommissioned_buckets: value.decommissioned_buckets.clone(),
|
|
bucket: value.bucket.clone(),
|
|
prefix: value.prefix.clone(),
|
|
object: value.object.clone(),
|
|
items_decommissioned: value.items_decommissioned,
|
|
items_decommission_failed: value.items_decommission_failed,
|
|
bytes_done: value.bytes_done,
|
|
bytes_failed: value.bytes_failed,
|
|
terminal_reload_attempt_at: value.terminal_reload_attempt_at,
|
|
terminal_reload_failures: value.terminal_reload_failures.clone(),
|
|
capacity_reservation: value.capacity_reservation.clone(),
|
|
capacity_blocked_reason: value.capacity_blocked_reason.clone(),
|
|
unresolved_entries: value.unresolved_entries.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&PoolDecommissionInfo> for PersistedPoolDecommissionInfoV1 {
|
|
fn from(value: &PoolDecommissionInfo) -> Self {
|
|
Self {
|
|
start_time: value.start_time,
|
|
start_size: value.start_size,
|
|
total_size: value.total_size,
|
|
current_size: value.current_size,
|
|
complete: value.complete,
|
|
failed: value.failed,
|
|
canceled: value.canceled,
|
|
queued: value.queued,
|
|
queued_buckets: value.queued_buckets.clone(),
|
|
decommissioned_buckets: value.decommissioned_buckets.clone(),
|
|
bucket: value.bucket.clone(),
|
|
prefix: value.prefix.clone(),
|
|
object: value.object.clone(),
|
|
items_decommissioned: value.items_decommissioned,
|
|
items_decommission_failed: value.items_decommission_failed,
|
|
bytes_done: value.bytes_done,
|
|
bytes_failed: value.bytes_failed,
|
|
terminal_reload_attempt_at: value.terminal_reload_attempt_at,
|
|
terminal_reload_failures: value.terminal_reload_failures.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PoolMetaSaveOutcome {
|
|
transaction_arm: PoolMetaTransactionArm,
|
|
committed: PoolMeta,
|
|
}
|
|
|
|
impl PoolMetaSaveOutcome {
|
|
fn disarm(mut self) {
|
|
self.transaction_arm.disarm();
|
|
}
|
|
|
|
fn into_committed(mut self) -> PoolMeta {
|
|
self.transaction_arm.disarm();
|
|
self.committed
|
|
}
|
|
}
|
|
|
|
impl PoolMeta {
|
|
fn current_decommission_movement_update(&self) -> Option<OffsetDateTime> {
|
|
self.pools
|
|
.iter()
|
|
.filter(|pool| pool.decommission.is_some())
|
|
.map(|pool| pool.last_update)
|
|
.max()
|
|
}
|
|
|
|
fn current_rebalance_movement_update(rebalance_meta: Option<&RebalanceMeta>) -> Option<OffsetDateTime> {
|
|
rebalance_meta
|
|
.into_iter()
|
|
.flat_map(|meta| {
|
|
meta.stopped_at.into_iter().chain(
|
|
meta.pool_stats
|
|
.iter()
|
|
.flat_map(|pool| [pool.info.start_time, pool.info.end_time])
|
|
.flatten(),
|
|
)
|
|
})
|
|
.max()
|
|
}
|
|
|
|
pub(crate) fn next_scanner_data_movement_update(
|
|
&self,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> OffsetDateTime {
|
|
let max_seen = self
|
|
.current_decommission_movement_update()
|
|
.max(Self::current_rebalance_movement_update(rebalance_meta));
|
|
match max_seen {
|
|
Some(max_seen) => max_seen
|
|
.checked_add(Duration::nanoseconds(1))
|
|
.map(|next| now.max(next))
|
|
.unwrap_or(max_seen),
|
|
None => now,
|
|
}
|
|
}
|
|
|
|
fn decode_pool_meta_payload(version: u16, payload: &[u8]) -> Result<Self> {
|
|
match version {
|
|
POOL_META_VERSION => rmp_serde::from_slice::<PersistedPoolMeta>(payload)
|
|
.map_err(|err| Error::other(format!("PoolMeta v{POOL_META_VERSION} decode failed: {err}")))?
|
|
.try_into(),
|
|
POOL_META_V1_VERSION => match rmp_serde::from_slice::<PersistedPoolMetaV1>(payload) {
|
|
Ok(meta) => meta.try_into(),
|
|
Err(persisted_err) => {
|
|
let legacy: LegacyPoolMeta = rmp_serde::from_slice(payload).map_err(|legacy_err| {
|
|
Error::other(format!(
|
|
"PoolMeta v1 decode failed for both persisted and legacy formats: persisted={persisted_err}; legacy={legacy_err}"
|
|
))
|
|
})?;
|
|
legacy.try_into()
|
|
}
|
|
},
|
|
_ => Err(Error::other(format!("pool metadata load failed: unknown version {version}"))),
|
|
}
|
|
}
|
|
|
|
pub fn new(pools: &[Arc<Sets>], prev_meta: &PoolMeta) -> Self {
|
|
let mut new_meta = Self {
|
|
version: if prev_meta.version == POOL_META_GENERATION_VERSION || pool_meta_v3_writer_enabled() {
|
|
POOL_META_GENERATION_VERSION
|
|
} else if prev_meta.version == POOL_META_VERSION || pool_meta_v2_writer_enabled() {
|
|
POOL_META_VERSION
|
|
} else {
|
|
POOL_META_V1_VERSION
|
|
},
|
|
pools: Vec::new(),
|
|
..Default::default()
|
|
};
|
|
|
|
for (idx, pool) in pools.iter().enumerate() {
|
|
let mut skip = false;
|
|
|
|
for current_pool in prev_meta.pools.iter() {
|
|
if current_pool.cmd_line == pool.endpoints.cmd_line {
|
|
new_meta.pools.push(current_pool.clone());
|
|
skip = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if skip {
|
|
continue;
|
|
}
|
|
|
|
new_meta.pools.push(PoolStatus {
|
|
cmd_line: pool.endpoints.cmd_line.clone(),
|
|
id: idx,
|
|
last_update: OffsetDateTime::now_utc(),
|
|
decommission: None,
|
|
});
|
|
}
|
|
|
|
new_meta
|
|
}
|
|
|
|
pub fn is_suspended(&self, idx: usize) -> bool {
|
|
self.pools
|
|
.get(idx)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.is_some_and(is_decommission_suspended)
|
|
}
|
|
|
|
fn mark_decommission_progress_saved(&mut self) {
|
|
for pool in &mut self.pools {
|
|
if let Some(info) = pool.decommission.as_mut() {
|
|
info.mark_progress_saved();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn decommission_progress_checkpoint(
|
|
&self,
|
|
idx: usize,
|
|
duration: Duration,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> Result<Option<DecommissionProgressCheckpoint>> {
|
|
let pool_count = self.pools.len();
|
|
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
|
|
|
let Some(pool) = self.pools.get(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_ref() else {
|
|
return Err(decommission_metadata_not_initialized_error("update decommission metadata timestamp"));
|
|
};
|
|
|
|
if info.progress_save_retry_after.is_some_and(|retry_after| now < retry_after) {
|
|
return Ok(None);
|
|
}
|
|
|
|
let time_threshold_reached = now.unix_timestamp() - pool.last_update.unix_timestamp() >= duration.whole_seconds();
|
|
let item_threshold_reached = info.items_since_last_progress_save() >= DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD;
|
|
if !time_threshold_reached && !item_threshold_reached {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(DecommissionProgressCheckpoint {
|
|
start_time: info.start_time,
|
|
queued: info.queued,
|
|
counted_items: info.counted_items(),
|
|
checkpoint_at: self.next_scanner_data_movement_update(now, rebalance_meta),
|
|
capacity_operation_id: info
|
|
.capacity_reservation
|
|
.as_ref()
|
|
.filter(|reservation| reservation.lease_active_at(now))
|
|
.map(|reservation| reservation.operation_id),
|
|
capacity_owner_nonce: info
|
|
.capacity_reservation
|
|
.as_ref()
|
|
.filter(|reservation| reservation.lease_active_at(now))
|
|
.map(|reservation| reservation.owner_nonce),
|
|
capacity_lease_expires_at: info
|
|
.capacity_reservation
|
|
.as_ref()
|
|
.filter(|reservation| reservation.lease_active_at(now))
|
|
.map(|_| now + DECOMMISSION_CAPACITY_RESERVATION_TTL),
|
|
}))
|
|
}
|
|
|
|
fn commit_decommission_progress_checkpoint(&mut self, idx: usize, checkpoint: DecommissionProgressCheckpoint) -> bool {
|
|
let Some(pool) = self.pools.get_mut(idx) else {
|
|
return false;
|
|
};
|
|
let Some(info) = pool.decommission.as_mut() else {
|
|
return false;
|
|
};
|
|
|
|
if info.start_time != checkpoint.start_time
|
|
|| info.queued != checkpoint.queued
|
|
|| !is_decommission_active(info.complete, info.failed, info.canceled)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
info.progress_save_item_baseline = info.progress_save_item_baseline.max(checkpoint.counted_items);
|
|
info.progress_save_retry_after = None;
|
|
if let (Some(operation_id), Some(owner_nonce), Some(expires_at), Some(reservation)) = (
|
|
checkpoint.capacity_operation_id,
|
|
checkpoint.capacity_owner_nonce,
|
|
checkpoint.capacity_lease_expires_at,
|
|
info.capacity_reservation.as_mut(),
|
|
) && reservation.operation_id == operation_id
|
|
&& reservation.owner_nonce == owner_nonce
|
|
&& reservation.active()
|
|
{
|
|
reservation.renewed_at = checkpoint.checkpoint_at;
|
|
reservation.expires_at = expires_at;
|
|
}
|
|
pool.last_update = pool.last_update.max(checkpoint.checkpoint_at);
|
|
true
|
|
}
|
|
|
|
fn defer_decommission_progress_checkpoint(
|
|
&mut self,
|
|
idx: usize,
|
|
checkpoint: DecommissionProgressCheckpoint,
|
|
retry_after: OffsetDateTime,
|
|
) {
|
|
let Some(pool) = self.pools.get_mut(idx) else {
|
|
return;
|
|
};
|
|
let Some(info) = pool.decommission.as_mut() else {
|
|
return;
|
|
};
|
|
|
|
if info.start_time == checkpoint.start_time
|
|
&& info.queued == checkpoint.queued
|
|
&& is_decommission_active(info.complete, info.failed, info.canceled)
|
|
{
|
|
info.progress_save_retry_after = Some(retry_after);
|
|
}
|
|
}
|
|
|
|
fn load_from_config_data(&mut self, data: Vec<u8>) -> Result<()> {
|
|
if data.is_empty() {
|
|
return Ok(());
|
|
} else if data.len() <= 4 {
|
|
return Err(Error::other("pool metadata load failed: metadata payload is too short"));
|
|
}
|
|
|
|
let format = LittleEndian::read_u16(&data[0..2]);
|
|
if format != POOL_META_FORMAT {
|
|
return Err(Error::other(format!("pool metadata load failed: unknown format {format}")));
|
|
}
|
|
let version = LittleEndian::read_u16(&data[2..4]);
|
|
if !matches!(version, POOL_META_V1_VERSION | POOL_META_VERSION | POOL_META_GENERATION_VERSION) {
|
|
return Err(Error::other(format!("pool metadata load failed: unknown version {version}")));
|
|
}
|
|
|
|
if version == POOL_META_GENERATION_VERSION {
|
|
let PoolMetaReplica::Valid {
|
|
meta, committed: true, ..
|
|
} = decode_pool_meta_replica(data)
|
|
else {
|
|
return Err(Error::other(
|
|
"pool metadata load failed: V3 payload is corrupt, incompatible, or not committed",
|
|
));
|
|
};
|
|
*self = meta;
|
|
return Ok(());
|
|
}
|
|
|
|
*self = Self::decode_pool_meta_payload(version, &data[4..])?;
|
|
|
|
if !matches!(self.version, POOL_META_V1_VERSION | POOL_META_VERSION | POOL_META_GENERATION_VERSION) {
|
|
return Err(Error::other(format!(
|
|
"pool metadata load failed: unexpected decoded version {}",
|
|
self.version
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn load(&mut self, pool: Arc<Sets>, pools: Vec<Arc<Sets>>) -> Result<()> {
|
|
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
|
|
let _pool_meta_guard = pool_meta_lock.get_read_lock(get_lock_acquire_timeout()).await?;
|
|
let replica_state = self.load_no_lock_from_replicas(pools).await?;
|
|
replica_state.ensure_write_safe("pool metadata load failed")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Loads every pool metadata replica while the caller owns the metadata fence
|
|
/// or before the namespace-lock RPC surface is ready during startup.
|
|
pub(crate) async fn load_no_lock_from_replicas<S>(&mut self, pools: Vec<Arc<S>>) -> Result<PoolMetaReplicaState>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let selection = load_pool_meta_replicas(pools, true).await?;
|
|
*self = selection.meta;
|
|
Ok(selection.replica_state)
|
|
}
|
|
|
|
pub(crate) async fn load_no_lock_from_replicas_observing<S>(
|
|
&mut self,
|
|
pools: Vec<Arc<S>>,
|
|
write_state: &mut PoolMetaWriteState,
|
|
) -> Result<PoolMetaReplicaState>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let selection = load_pool_meta_replicas_observing(pools, true, write_state).await?;
|
|
*self = selection.meta;
|
|
Ok(selection.replica_state)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn encode_config_data(&self) -> Result<Vec<u8>> {
|
|
self.encode_config_data_for_v2_gate(pool_meta_v2_writer_enabled())
|
|
}
|
|
|
|
fn encode_config_data_for_v2_gate(&self, v2_enabled: bool) -> Result<Vec<u8>> {
|
|
if self.dont_save {
|
|
return Ok(Vec::new());
|
|
}
|
|
if self.version == POOL_META_GENERATION_VERSION {
|
|
return Err(Error::other(
|
|
"pool metadata V3 save requires cluster identity and generation transaction context",
|
|
));
|
|
}
|
|
if !matches!(self.version, 0 | POOL_META_V1_VERSION | POOL_META_VERSION) {
|
|
return Err(Error::other(format!(
|
|
"pool metadata save failed: unexpected runtime version {}",
|
|
self.version
|
|
)));
|
|
}
|
|
let version = if self.version == POOL_META_VERSION || v2_enabled {
|
|
POOL_META_VERSION
|
|
} else {
|
|
POOL_META_V1_VERSION
|
|
};
|
|
if version == POOL_META_V1_VERSION {
|
|
let mut decommission_infos = self.pools.iter().filter_map(|pool| pool.decommission.as_ref());
|
|
if decommission_infos.clone().any(|info| !info.unresolved_entries.is_empty()) {
|
|
return Err(Error::other(format!(
|
|
"pool metadata V2 is required to persist unresolved decommission entries; enable both {} and {} only after every reader and writer supports V2",
|
|
rustfs_config::ENV_POOL_META_V2_WRITE,
|
|
rustfs_config::ENV_POOL_META_V2_FLEET_CONFIRMED,
|
|
)));
|
|
}
|
|
if decommission_infos.any(|info| info.capacity_reservation.is_some()) {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"pool metadata V2 is required to persist decommission capacity reservations; enable both {} and {} only after every reader and writer supports V2",
|
|
rustfs_config::ENV_POOL_META_V2_WRITE,
|
|
rustfs_config::ENV_POOL_META_V2_FLEET_CONFIRMED,
|
|
)));
|
|
}
|
|
}
|
|
let mut data = Vec::new();
|
|
data.write_u16::<LittleEndian>(POOL_META_FORMAT)?;
|
|
data.write_u16::<LittleEndian>(version)?;
|
|
let mut buf = Vec::new();
|
|
match version {
|
|
POOL_META_V1_VERSION => PersistedPoolMetaV1::from(self).serialize(&mut Serializer::new(&mut buf))?,
|
|
POOL_META_VERSION => PersistedPoolMeta::from(self).serialize(&mut Serializer::new(&mut buf))?,
|
|
_ => unreachable!("pool metadata writer selected an unsupported version"),
|
|
}
|
|
data.write_all(&buf)?;
|
|
Ok(data)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn encode_config_data_for_test(&self) -> Result<Vec<u8>> {
|
|
self.encode_config_data_for_v2_gate(true)
|
|
}
|
|
|
|
pub async fn save(&self, pools: Vec<Arc<Sets>>) -> Result<()> {
|
|
let pool = pools
|
|
.first()
|
|
.cloned()
|
|
.ok_or_else(|| Error::other("pool metadata save failed: no storage pools available"))?;
|
|
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
|
|
let pool_meta_guard = pool_meta_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
|
let mut write_state = PoolMetaWriteState::default();
|
|
let indices = (0..self.pools.len()).collect::<Vec<_>>();
|
|
let outcome = self
|
|
.save_no_lock_armed_scoped(pools, &mut write_state, pool_meta_guard.lock_lost_signal(), Some(&indices))
|
|
.await?;
|
|
outcome.disarm();
|
|
Ok(())
|
|
}
|
|
|
|
/// Startup has a single elected local writer, so it must not depend on namespace locks here.
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
pub(crate) async fn save_for_startup<S>(&self, pools: Vec<Arc<S>>) -> Result<()>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let mut write_state = PoolMetaWriteState::for_test_bootstrap();
|
|
self.save_for_startup_observing(pools, &mut write_state).await.map(|_| ())
|
|
}
|
|
|
|
pub(crate) async fn save_for_startup_observing<S>(
|
|
&self,
|
|
pools: Vec<Arc<S>>,
|
|
write_state: &mut PoolMetaWriteState,
|
|
) -> Result<PoolMeta>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let outcome = self.save_no_lock_armed_scoped(pools, write_state, None, None).await?;
|
|
Ok(outcome.into_committed())
|
|
}
|
|
|
|
async fn save_no_lock_with_fence<S>(
|
|
&self,
|
|
pools: Vec<Arc<S>>,
|
|
lock_lost: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
|
indices: &[usize],
|
|
) -> Result<()>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let mut write_state = PoolMetaWriteState::default();
|
|
let outcome = self
|
|
.save_no_lock_armed_scoped(pools, &mut write_state, lock_lost, Some(indices))
|
|
.await?;
|
|
outcome.disarm();
|
|
Ok(())
|
|
}
|
|
|
|
async fn save_no_lock_with_activation_fence<S>(
|
|
&self,
|
|
pools: Vec<Arc<S>>,
|
|
write_state: &mut PoolMetaWriteState,
|
|
activation_fence: &PoolRebalanceActivationFence,
|
|
indices: &[usize],
|
|
) -> Result<PoolMetaSaveOutcome>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
write_state.ensure_write_safe("pool metadata activation save failed")?;
|
|
let transaction_arm = write_state.arm_transaction();
|
|
let fence = PoolMetaPersistenceFence::Activation(activation_fence);
|
|
let committed = self
|
|
.save_no_lock_transaction(pools, write_state, &fence, Some(indices))
|
|
.await?;
|
|
Ok(PoolMetaSaveOutcome {
|
|
transaction_arm,
|
|
committed,
|
|
})
|
|
}
|
|
|
|
async fn save_no_lock_armed<S>(
|
|
&self,
|
|
pools: Vec<Arc<S>>,
|
|
write_state: &mut PoolMetaWriteState,
|
|
lock_lost: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
|
indices: &[usize],
|
|
) -> Result<PoolMetaSaveOutcome>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
self.save_no_lock_armed_scoped(pools, write_state, lock_lost, Some(indices))
|
|
.await
|
|
}
|
|
|
|
async fn save_no_lock_armed_scoped<S>(
|
|
&self,
|
|
pools: Vec<Arc<S>>,
|
|
write_state: &mut PoolMetaWriteState,
|
|
lock_lost: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
|
indices: Option<&[usize]>,
|
|
) -> Result<PoolMetaSaveOutcome>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
write_state.ensure_write_safe("pool metadata save failed")?;
|
|
// The arm does not block its own transaction. If this future or its
|
|
// returned outcome is dropped before publication, Drop latches the
|
|
// sticky recovery gate.
|
|
let transaction_arm = write_state.arm_transaction();
|
|
let fence = PoolMetaPersistenceFence::Distributed(lock_lost);
|
|
let committed = self.save_no_lock_transaction(pools, write_state, &fence, indices).await?;
|
|
Ok(PoolMetaSaveOutcome {
|
|
transaction_arm,
|
|
committed,
|
|
})
|
|
}
|
|
|
|
async fn save_no_lock_transaction<S>(
|
|
&self,
|
|
pools: Vec<Arc<S>>,
|
|
write_state: &mut PoolMetaWriteState,
|
|
fence: &PoolMetaPersistenceFence<'_>,
|
|
indices: Option<&[usize]>,
|
|
) -> Result<PoolMeta>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
if pools.is_empty() {
|
|
return Err(Error::other("pool metadata save failed: no storage pools available"));
|
|
}
|
|
if self.dont_save {
|
|
return Ok(self.clone());
|
|
}
|
|
for attempt in 0..POOL_META_CAS_MAX_ATTEMPTS {
|
|
match self
|
|
.save_no_lock_transaction_once(pools.clone(), write_state, fence, indices)
|
|
.await
|
|
{
|
|
Ok(committed) => return Ok(committed),
|
|
Err(Error::PreconditionFailed) if attempt + 1 < POOL_META_CAS_MAX_ATTEMPTS => continue,
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
Err(Error::PreconditionFailed)
|
|
}
|
|
|
|
async fn save_no_lock_transaction_once<S>(
|
|
&self,
|
|
pools: Vec<Arc<S>>,
|
|
write_state: &mut PoolMetaWriteState,
|
|
fence: &PoolMetaPersistenceFence<'_>,
|
|
indices: Option<&[usize]>,
|
|
) -> Result<PoolMeta>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
fence.ensure_held()?;
|
|
let selection = load_pool_meta_replicas_observing(pools.clone(), true, write_state).await?;
|
|
write_state.observe_replicas(selection.replica_state);
|
|
write_state.ensure_write_safe("pool metadata save failed")?;
|
|
let mut bootstrap_generation_required = false;
|
|
if let Some(cluster_id) = write_state.expected_cluster_id {
|
|
let identity = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
|
bootstrap_generation_required = selection.absent || write_state.identity_initialized == Some(false);
|
|
if !identity.repair_write_safe {
|
|
write_state.block_writes();
|
|
return Err(Error::other(
|
|
"pool metadata recovery required: cluster identity has an unreadable replica",
|
|
));
|
|
}
|
|
if let Some(identity) = identity.identity
|
|
&& selection.revision.is_generation_protocol()
|
|
&& identity.epoch != selection.revision.epoch
|
|
{
|
|
write_state.block_writes();
|
|
return Err(Error::other(format!(
|
|
"pool metadata recovery required: committed epoch {} does not match cluster identity epoch {}",
|
|
selection.revision.epoch, identity.epoch
|
|
)));
|
|
}
|
|
if !selection.absent && write_state.identity_requires_repair() {
|
|
let initialized = write_state.identity_initialized != Some(false) || selection.revision.is_generation_protocol();
|
|
persist_pool_meta_identity(pools.clone(), write_state, initialized, fence).await?;
|
|
}
|
|
}
|
|
// Startup is the only path allowed to create an all-missing metadata
|
|
// set. Runtime callers without an identity context must fail closed.
|
|
write_state.ensure_missing_metadata_can_initialize()?;
|
|
|
|
let mut committed = if let Some(indices) = indices {
|
|
if selection.meta.pools.is_empty() {
|
|
self.clone()
|
|
} else {
|
|
let mut requested = self.clone();
|
|
requested.version = selection.meta.version;
|
|
let mut latest = selection.meta.clone();
|
|
merge_pool_meta_updates_for_save(&mut latest, &requested, indices, "pool metadata save failed")?;
|
|
latest
|
|
}
|
|
} else {
|
|
self.clone()
|
|
};
|
|
let target_version = if selection.generation_protocol_observed
|
|
|| selection.revision.is_generation_protocol()
|
|
|| pool_meta_v3_writer_enabled()
|
|
|| bootstrap_generation_required
|
|
{
|
|
POOL_META_GENERATION_VERSION
|
|
} else if selection.meta.version == POOL_META_VERSION
|
|
|| self.version == POOL_META_VERSION
|
|
|| pool_meta_v2_writer_enabled()
|
|
{
|
|
POOL_META_VERSION
|
|
} else {
|
|
POOL_META_V1_VERSION
|
|
};
|
|
committed.version = target_version;
|
|
|
|
if target_version != POOL_META_GENERATION_VERSION {
|
|
let data = committed.encode_config_data_for_v2_gate(target_version == POOL_META_VERSION)?;
|
|
let mut canonical_saved = false;
|
|
for (pool_index, (pool, token)) in pools.iter().cloned().zip(&selection.cas_tokens).enumerate() {
|
|
let result =
|
|
save_pool_meta_object_cas(pool.clone(), POOL_META_NAME, data.clone(), token, fence, "legacy_cas").await;
|
|
if result.is_ok() && pool_index == 0 {
|
|
canonical_saved = true;
|
|
#[cfg(test)]
|
|
if let PoolMetaPersistenceFence::Activation(activation_fence) = fence {
|
|
pause_pool_activation_after_durable_save(&pool, activation_fence).await;
|
|
}
|
|
}
|
|
if let Err(err) = result {
|
|
if canonical_saved && fence.is_activation() {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index,
|
|
state = "activation_replica_repair_pending",
|
|
error = %err,
|
|
"Decommission activation replica repair pending"
|
|
);
|
|
continue;
|
|
}
|
|
return Err(err);
|
|
}
|
|
}
|
|
let confirmed = if fence.is_activation() {
|
|
load_pool_meta_replicas(pools, true).await?
|
|
} else {
|
|
let confirmed = load_pool_meta_replicas_observing(pools, true, write_state).await?;
|
|
write_state.observe_replicas(confirmed.replica_state);
|
|
confirmed
|
|
};
|
|
let expected = committed.encode_config_data_for_v2_gate(true)?;
|
|
if confirmed.canonical.as_ref() != Some(&expected) {
|
|
record_pool_meta_stale_write_rejection("legacy_verify");
|
|
return Err(Error::PreconditionFailed);
|
|
}
|
|
return Ok(confirmed.meta);
|
|
}
|
|
|
|
let cluster_id = selection
|
|
.revision
|
|
.cluster_id
|
|
.or(selection.generation_identity.map(|(cluster_id, _)| cluster_id))
|
|
.or(write_state.expected_cluster_id)
|
|
.ok_or_else(|| Error::other("pool metadata V3 save failed: cluster identity is not initialized"))?;
|
|
let epoch = if selection.revision.is_generation_protocol() {
|
|
selection.revision.epoch
|
|
} else {
|
|
selection
|
|
.generation_identity
|
|
.map(|(_, epoch)| epoch)
|
|
.or(write_state.cluster_epoch)
|
|
.unwrap_or(POOL_META_INITIAL_EPOCH)
|
|
};
|
|
let generation = if selection.revision.is_generation_protocol() {
|
|
selection
|
|
.revision
|
|
.generation
|
|
.checked_add(1)
|
|
.ok_or_else(|| Error::other("pool metadata V3 generation exhausted"))?
|
|
} else {
|
|
1
|
|
};
|
|
let revision = PoolMetaRevision {
|
|
version: POOL_META_GENERATION_VERSION,
|
|
cluster_id: Some(cluster_id),
|
|
epoch,
|
|
generation,
|
|
transaction_id: Some(uuid::Uuid::new_v4()),
|
|
};
|
|
let previous = if let Some(canonical) = selection.canonical.clone() {
|
|
PoolMetaCommittedCandidate {
|
|
canonical,
|
|
meta: selection.meta.clone(),
|
|
revision: selection.revision,
|
|
}
|
|
} else {
|
|
let empty = PoolMeta {
|
|
version: POOL_META_V1_VERSION,
|
|
..Default::default()
|
|
};
|
|
PoolMetaCommittedCandidate {
|
|
canonical: empty.encode_config_data_for_v2_gate(true)?,
|
|
meta: empty,
|
|
revision: PoolMetaRevision::legacy(POOL_META_V1_VERSION),
|
|
}
|
|
};
|
|
let pending = encode_pool_meta_v3_envelope(&committed, revision, false, Some(&previous))?;
|
|
let durable = encode_pool_meta_v3_envelope(&committed, revision, true, None)?;
|
|
let mut pending_tokens = Vec::with_capacity(pools.len());
|
|
for (pool, token) in pools.iter().cloned().zip(&selection.cas_tokens) {
|
|
let object_info =
|
|
save_pool_meta_object_cas(pool, POOL_META_NAME, pending.clone(), token, fence, "prepare_cas").await?;
|
|
let etag = object_info
|
|
.etag
|
|
.filter(|etag| !etag.trim().is_empty())
|
|
.ok_or_else(|| Error::other("pool metadata V3 prepare succeeded without a conditional-write revision"))?;
|
|
pending_tokens.push(PoolMetaCasToken::Existing(etag));
|
|
}
|
|
|
|
let mut commit_error = None;
|
|
let mut commit_succeeded = false;
|
|
#[cfg(test)]
|
|
let mut first_pool = true;
|
|
for (pool, token) in pools.iter().cloned().zip(&pending_tokens) {
|
|
match save_pool_meta_object_cas(pool.clone(), POOL_META_NAME, durable.clone(), token, fence, "commit_cas").await {
|
|
Ok(_) => {
|
|
commit_succeeded = true;
|
|
#[cfg(test)]
|
|
if first_pool && let PoolMetaPersistenceFence::Activation(activation_fence) = fence {
|
|
pause_pool_activation_after_durable_save(&pool, activation_fence).await;
|
|
}
|
|
}
|
|
Err(err) => {
|
|
commit_error.get_or_insert(err);
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
{
|
|
first_pool = false;
|
|
}
|
|
}
|
|
let confirmed = if fence.is_activation() {
|
|
load_pool_meta_replicas(pools.clone(), true).await?
|
|
} else {
|
|
let confirmed = load_pool_meta_replicas_observing(pools.clone(), true, write_state).await?;
|
|
write_state.observe_replicas(confirmed.replica_state);
|
|
confirmed
|
|
};
|
|
if confirmed.revision == revision && confirmed.canonical.as_ref() == Some(&durable) {
|
|
persist_pool_meta_identity(pools, write_state, true, fence).await?;
|
|
return Ok(confirmed.meta);
|
|
}
|
|
if !commit_succeeded {
|
|
return Err(commit_error.unwrap_or(Error::PreconditionFailed));
|
|
}
|
|
Err(commit_error.unwrap_or_else(|| {
|
|
Error::other("pool metadata recovery required: committed V3 transaction was not selected after write")
|
|
}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn save_no_lock_observing<S>(&self, pools: Vec<Arc<S>>, write_state: &mut PoolMetaWriteState) -> Result<()>
|
|
where
|
|
S: EcstoreObjectIO,
|
|
{
|
|
let indices = (0..self.pools.len()).collect::<Vec<_>>();
|
|
let outcome = self.save_no_lock_armed(pools, write_state, None, &indices).await?;
|
|
outcome.disarm();
|
|
Ok(())
|
|
}
|
|
|
|
pub fn decommission_cancel(&mut self, idx: usize) -> bool {
|
|
self.decommission_cancel_at(idx, OffsetDateTime::now_utc(), None)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn decommission_cancel_at_for_test(
|
|
&mut self,
|
|
idx: usize,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> bool {
|
|
self.decommission_cancel_at(idx, now, rebalance_meta)
|
|
}
|
|
|
|
fn decommission_cancel_at(&mut self, idx: usize, now: OffsetDateTime, rebalance_meta: Option<&RebalanceMeta>) -> bool {
|
|
let Some(d) = self.pools.get(idx).and_then(|stats| stats.decommission.as_ref()) else {
|
|
return false;
|
|
};
|
|
if d.canceled {
|
|
return false;
|
|
}
|
|
|
|
let last_update = self.next_scanner_data_movement_update(now, rebalance_meta);
|
|
let mut pd = d.clone();
|
|
pd.canceled = true;
|
|
pd.failed = false;
|
|
pd.complete = false;
|
|
pd.start_time = None;
|
|
pd.terminal_reload_attempt_at = None;
|
|
pd.terminal_reload_failures.clear();
|
|
pd.capacity_blocked_reason = None;
|
|
release_decommission_capacity_reservation(&mut pd, DECOMMISSION_CAPACITY_RELEASE_CANCELED, last_update);
|
|
|
|
let Some(stats) = self.pools.get_mut(idx) else {
|
|
return false;
|
|
};
|
|
stats.last_update = last_update;
|
|
stats.decommission = Some(pd);
|
|
true
|
|
}
|
|
pub fn decommission_failed(&mut self, idx: usize) -> bool {
|
|
self.decommission_failed_at(idx, OffsetDateTime::now_utc(), None)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn decommission_failed_at_for_test(
|
|
&mut self,
|
|
idx: usize,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> bool {
|
|
self.decommission_failed_at(idx, now, rebalance_meta)
|
|
}
|
|
|
|
fn decommission_failed_at(&mut self, idx: usize, now: OffsetDateTime, rebalance_meta: Option<&RebalanceMeta>) -> bool {
|
|
let Some(d) = self.pools.get(idx).and_then(|stats| stats.decommission.as_ref()) else {
|
|
return false;
|
|
};
|
|
if !is_decommission_active(d.complete, d.failed, d.canceled) {
|
|
return false;
|
|
}
|
|
|
|
let last_update = self.next_scanner_data_movement_update(now, rebalance_meta);
|
|
let mut pd = d.clone();
|
|
pd.canceled = false;
|
|
pd.failed = true;
|
|
pd.complete = false;
|
|
pd.start_time = None;
|
|
pd.terminal_reload_attempt_at = None;
|
|
pd.terminal_reload_failures.clear();
|
|
pd.capacity_blocked_reason = None;
|
|
release_decommission_capacity_reservation(&mut pd, DECOMMISSION_CAPACITY_RELEASE_FAILED, last_update);
|
|
|
|
let Some(stats) = self.pools.get_mut(idx) else {
|
|
return false;
|
|
};
|
|
stats.last_update = last_update;
|
|
stats.decommission = Some(pd);
|
|
true
|
|
}
|
|
|
|
pub fn clear_decommission(&mut self, idx: usize) -> Result<bool> {
|
|
self.clear_decommission_at(idx, OffsetDateTime::now_utc(), None)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn clear_decommission_at_for_test(
|
|
&mut self,
|
|
idx: usize,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> Result<bool> {
|
|
self.clear_decommission_at(idx, now, rebalance_meta)
|
|
}
|
|
|
|
fn clear_decommission_at(&mut self, idx: usize, now: OffsetDateTime, rebalance_meta: Option<&RebalanceMeta>) -> Result<bool> {
|
|
let pool_count = self.pools.len();
|
|
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
|
|
|
let Some(pool) = self.pools.get(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
|
|
let (decommission_present, complete, failed, canceled, unresolved_entries) = pool
|
|
.decommission
|
|
.as_ref()
|
|
.map(|info| {
|
|
(
|
|
info.has_decommission_state(),
|
|
info.complete,
|
|
info.failed,
|
|
info.canceled,
|
|
info.unresolved_entries.len(),
|
|
)
|
|
})
|
|
.unwrap_or((false, false, false, false, 0));
|
|
|
|
ensure_decommission_clear_allowed(true, decommission_present, complete, failed, canceled, unresolved_entries)?;
|
|
|
|
let last_update = self.next_scanner_data_movement_update(now, rebalance_meta);
|
|
let Some(pool) = self.pools.get_mut(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
pool.last_update = last_update;
|
|
// Preserve a state-empty tombstone so scanner catch-up can recover the
|
|
// durable movement generation after a clear followed by a restart.
|
|
pool.decommission = Some(PoolDecommissionInfo::default());
|
|
Ok(true)
|
|
}
|
|
|
|
pub fn decommission_complete(&mut self, idx: usize) -> bool {
|
|
self.decommission_complete_at(idx, OffsetDateTime::now_utc(), None)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn decommission_complete_at_for_test(
|
|
&mut self,
|
|
idx: usize,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> bool {
|
|
self.decommission_complete_at(idx, now, rebalance_meta)
|
|
}
|
|
|
|
fn decommission_complete_at(&mut self, idx: usize, now: OffsetDateTime, rebalance_meta: Option<&RebalanceMeta>) -> bool {
|
|
let Some(d) = self.pools.get(idx).and_then(|stats| stats.decommission.as_ref()) else {
|
|
return false;
|
|
};
|
|
if !is_decommission_active(d.complete, d.failed, d.canceled) {
|
|
return false;
|
|
}
|
|
|
|
let last_update = self.next_scanner_data_movement_update(now, rebalance_meta);
|
|
let mut pd = d.clone();
|
|
pd.canceled = false;
|
|
pd.failed = false;
|
|
pd.complete = true;
|
|
pd.terminal_reload_attempt_at = None;
|
|
pd.terminal_reload_failures.clear();
|
|
pd.capacity_blocked_reason = None;
|
|
release_decommission_capacity_reservation(&mut pd, DECOMMISSION_CAPACITY_RELEASE_COMPLETED, last_update);
|
|
|
|
let Some(stats) = self.pools.get_mut(idx) else {
|
|
return false;
|
|
};
|
|
stats.last_update = last_update;
|
|
stats.decommission = Some(pd);
|
|
true
|
|
}
|
|
fn set_decommission_state_at(
|
|
&mut self,
|
|
idx: usize,
|
|
pi: PoolSpaceInfo,
|
|
queued: bool,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> Result<()> {
|
|
let pool_count = self.pools.len();
|
|
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
|
|
|
let Some(pool) = self.pools.get(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
|
|
ensure_decommission_start_allowed(decommission_start_pool_state(Some(pool)))?;
|
|
|
|
let generation = self.next_scanner_data_movement_update(now, rebalance_meta);
|
|
let Some(pool) = self.pools.get_mut(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let previous = pool.decommission.as_ref();
|
|
pool.last_update = generation;
|
|
pool.decommission = Some(build_decommission_start_state(pi, queued, generation, previous));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn set_decommission_state(&mut self, idx: usize, pi: PoolSpaceInfo, queued: bool) -> Result<()> {
|
|
self.set_decommission_state_at(idx, pi, queued, OffsetDateTime::now_utc(), None)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn decommission_at_for_test(
|
|
&mut self,
|
|
idx: usize,
|
|
pi: PoolSpaceInfo,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> Result<()> {
|
|
self.set_decommission_state_at(idx, pi, false, now, rebalance_meta)
|
|
}
|
|
|
|
pub fn decommission(&mut self, idx: usize, pi: PoolSpaceInfo) -> Result<()> {
|
|
self.set_decommission_state(idx, pi, false)
|
|
}
|
|
|
|
pub fn queue_decommission(&mut self, idx: usize, pi: PoolSpaceInfo) -> Result<()> {
|
|
self.set_decommission_state(idx, pi, true)
|
|
}
|
|
|
|
pub fn record_decommission_terminal_reload_failure(&mut self, idx: usize, stage: &str, message: String) -> Result<bool> {
|
|
self.record_decommission_terminal_reload_failure_at(idx, stage, message, OffsetDateTime::now_utc(), None)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn record_decommission_terminal_reload_failure_at_for_test(
|
|
&mut self,
|
|
idx: usize,
|
|
stage: &str,
|
|
message: String,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> Result<bool> {
|
|
self.record_decommission_terminal_reload_failure_at(idx, stage, message, now, rebalance_meta)
|
|
}
|
|
|
|
fn record_decommission_terminal_reload_failure_at(
|
|
&mut self,
|
|
idx: usize,
|
|
stage: &str,
|
|
message: String,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> Result<bool> {
|
|
let pool_count = self.pools.len();
|
|
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
|
|
|
let Some(pool) = self.pools.get(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_ref() else {
|
|
return Err(decommission_metadata_not_initialized_error("record decommission terminal reload failure"));
|
|
};
|
|
|
|
let failure = format!("{stage}: {message}");
|
|
if info.terminal_reload_failures.last().is_some_and(|last| last == &failure) {
|
|
return Ok(false);
|
|
}
|
|
|
|
let last_update = self.next_scanner_data_movement_update(now, rebalance_meta);
|
|
let Some(pool) = self.pools.get_mut(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_mut() else {
|
|
return Err(decommission_metadata_not_initialized_error("record decommission terminal reload failure"));
|
|
};
|
|
pool.last_update = last_update;
|
|
info.terminal_reload_attempt_at = Some(last_update);
|
|
info.terminal_reload_failures.push(failure);
|
|
Ok(true)
|
|
}
|
|
|
|
fn mark_decommission_capacity_blocked(&mut self, idx: usize, reason: String, now: OffsetDateTime) -> Result<bool> {
|
|
let pool_count = self.pools.len();
|
|
let pool = self
|
|
.pools
|
|
.get_mut(idx)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, idx))?;
|
|
let info = pool
|
|
.decommission
|
|
.as_mut()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("pause decommission for target capacity"))?;
|
|
if !is_decommission_active(info.complete, info.failed, info.canceled) {
|
|
return Ok(false);
|
|
}
|
|
if let Some(reservation) = info.capacity_reservation.as_mut().filter(|reservation| reservation.active()) {
|
|
renew_decommission_capacity_reservation(reservation, now, true);
|
|
}
|
|
let changed = info.capacity_blocked_reason.as_deref() != Some(reason.as_str());
|
|
info.capacity_blocked_reason = Some(reason);
|
|
pool.last_update = now;
|
|
Ok(changed)
|
|
}
|
|
|
|
pub fn promote_queued_decommission(&mut self, idx: usize) -> bool {
|
|
self.promote_queued_decommission_at(idx, OffsetDateTime::now_utc(), None)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn promote_queued_decommission_at_for_test(
|
|
&mut self,
|
|
idx: usize,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> bool {
|
|
self.promote_queued_decommission_at(idx, now, rebalance_meta)
|
|
}
|
|
|
|
fn promote_queued_decommission_at(
|
|
&mut self,
|
|
idx: usize,
|
|
now: OffsetDateTime,
|
|
rebalance_meta: Option<&RebalanceMeta>,
|
|
) -> bool {
|
|
let Some(info) = self.pools.get(idx).and_then(|pool| pool.decommission.as_ref()) else {
|
|
return false;
|
|
};
|
|
if !info.queued || !is_decommission_active(info.complete, info.failed, info.canceled) {
|
|
return false;
|
|
}
|
|
|
|
let generation = self.next_scanner_data_movement_update(now, rebalance_meta);
|
|
let Some(pool) = self.pools.get_mut(idx) else {
|
|
return false;
|
|
};
|
|
let Some(info) = pool.decommission.as_mut() else {
|
|
return false;
|
|
};
|
|
pool.last_update = generation;
|
|
info.queued = false;
|
|
info.start_time = Some(generation);
|
|
for entry in &mut info.unresolved_entries {
|
|
entry.source_generation = generation;
|
|
}
|
|
true
|
|
}
|
|
pub fn queue_buckets(&mut self, idx: usize, bks: Vec<DecomBucketInfo>) {
|
|
if let Some(pool) = self.pools.get_mut(idx)
|
|
&& let Some(dec) = pool.decommission.as_mut()
|
|
{
|
|
for bk in bks.iter() {
|
|
dec.bucket_push(bk);
|
|
}
|
|
}
|
|
}
|
|
pub fn pending_buckets(&self, idx: usize) -> Vec<DecomBucketInfo> {
|
|
let mut list = Vec::new();
|
|
|
|
if let Some(pool) = self.pools.get(idx)
|
|
&& let Some(ref info) = pool.decommission
|
|
{
|
|
for bk in info.queued_buckets.iter() {
|
|
let (name, prefix) = path2_bucket_object(bk);
|
|
list.push(DecomBucketInfo { name, prefix });
|
|
}
|
|
}
|
|
|
|
list
|
|
}
|
|
|
|
pub fn is_bucket_decommissioned(&self, idx: usize, bucket: String) -> bool {
|
|
self.pools
|
|
.get(idx)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.is_some_and(|info| info.is_bucket_decommissioned(&bucket))
|
|
}
|
|
|
|
pub fn bucket_done(&mut self, idx: usize, bucket: String) -> bool {
|
|
if let Some(pool) = self.pools.get_mut(idx) {
|
|
if let Some(info) = pool.decommission.as_mut() {
|
|
info.bucket_pop(&bucket)
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn count_item(&mut self, idx: usize, size: usize, failed: bool) {
|
|
if let Some(pool) = self.pools.get_mut(idx)
|
|
&& let Some(info) = pool.decommission.as_mut()
|
|
{
|
|
if failed {
|
|
info.items_decommission_failed += 1;
|
|
info.bytes_failed += size;
|
|
} else {
|
|
info.items_decommissioned += 1;
|
|
info.bytes_done += size;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn track_current_bucket_object(&mut self, idx: usize, bucket: String, object: String) {
|
|
self.track_current_bucket_object_stage(idx, bucket, object, String::new());
|
|
}
|
|
|
|
pub fn track_current_bucket_object_stage(&mut self, idx: usize, bucket: String, object: String, stage: String) {
|
|
if self.pools.get(idx).is_none_or(|v| v.decommission.is_none()) {
|
|
return;
|
|
}
|
|
|
|
if let Some(pool) = self.pools.get_mut(idx)
|
|
&& let Some(info) = pool.decommission.as_mut()
|
|
{
|
|
info.object = object;
|
|
info.bucket = bucket;
|
|
info.stage = stage;
|
|
}
|
|
}
|
|
|
|
pub fn update_after(&mut self, idx: usize, duration: Duration) -> Result<bool> {
|
|
Ok(self
|
|
.decommission_progress_checkpoint(idx, duration, OffsetDateTime::now_utc(), None)?
|
|
.is_some())
|
|
}
|
|
|
|
pub fn validate(&self, pools: Vec<Arc<Sets>>) -> Result<bool> {
|
|
struct PoolInfo {
|
|
position: usize,
|
|
completed: bool,
|
|
#[allow(dead_code, reason = "written but never read back (backlog#1823)")]
|
|
decom_started: bool,
|
|
}
|
|
|
|
let mut remembered_pools = HashMap::new();
|
|
for (idx, pool) in self.pools.iter().enumerate() {
|
|
let mut complete = false;
|
|
let mut decom_started = false;
|
|
if let Some(decommission) = &pool.decommission {
|
|
if decommission.complete {
|
|
complete = true;
|
|
}
|
|
decom_started = true;
|
|
}
|
|
remembered_pools.insert(
|
|
pool.cmd_line.clone(),
|
|
PoolInfo {
|
|
position: idx,
|
|
completed: complete,
|
|
decom_started,
|
|
},
|
|
);
|
|
}
|
|
|
|
let mut specified_pools = HashMap::new();
|
|
for (idx, pool) in pools.iter().enumerate() {
|
|
specified_pools.insert(pool.endpoints.cmd_line.clone(), idx);
|
|
}
|
|
|
|
let mut update = false;
|
|
|
|
// Determine whether the selected pool should be removed from the retired list.
|
|
for k in specified_pools.keys() {
|
|
if let Some(pi) = remembered_pools.get(k) {
|
|
ensure_pool_not_left_in_cmdline_after_decommission(pi.position, k, pi.completed)?;
|
|
} else {
|
|
// If the previous pool no longer exists, allow updates because a new pool may have been added.
|
|
update = true;
|
|
}
|
|
}
|
|
|
|
if specified_pools.len() == remembered_pools.len() {
|
|
for (k, pi) in remembered_pools.iter() {
|
|
if let Some(pos) = specified_pools.get(k)
|
|
&& *pos != pi.position
|
|
{
|
|
update = true; // Pool order changed, allow the update.
|
|
}
|
|
}
|
|
}
|
|
|
|
if !update {
|
|
update = specified_pools.len() != remembered_pools.len();
|
|
}
|
|
|
|
Ok(update)
|
|
}
|
|
|
|
pub fn return_resumable_pools(&self) -> Vec<PoolStatus> {
|
|
resumable_decommission_queue_indices(self)
|
|
.into_iter()
|
|
.map(|idx| self.pools[idx].clone())
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
pub fn path2_bucket_object(name: &str) -> (String, String) {
|
|
path_to_bucket_object(name)
|
|
}
|
|
|
|
pub fn path2_bucket_object_with_base_path(base_path: &str, path: &str) -> (String, String) {
|
|
path_to_bucket_object_with_base_path(base_path, path)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DecommissionUnresolvedEntry {
|
|
pub bucket: String,
|
|
pub object: String,
|
|
#[serde(rename = "poolIndex")]
|
|
pub pool_index: usize,
|
|
#[serde(rename = "setIndex")]
|
|
pub set_index: usize,
|
|
#[serde(rename = "sourceGeneration", with = "time::serde::rfc3339")]
|
|
pub source_generation: OffsetDateTime,
|
|
#[serde(rename = "candidateCount")]
|
|
pub candidate_count: usize,
|
|
#[serde(rename = "diskErrorCount")]
|
|
pub disk_error_count: usize,
|
|
#[serde(rename = "observedAt", with = "time::serde::rfc3339")]
|
|
pub observed_at: OffsetDateTime,
|
|
pub reason: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct DecommissionErasureLayout {
|
|
pub data: usize,
|
|
pub parity: usize,
|
|
}
|
|
|
|
impl DecommissionErasureLayout {
|
|
fn width(self) -> usize {
|
|
self.data.saturating_add(self.parity)
|
|
}
|
|
|
|
fn is_valid(self) -> bool {
|
|
self.data > 0 && self.data.checked_add(self.parity).is_some()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct DecommissionCapacityTarget {
|
|
pub pool_index: usize,
|
|
pub layout: DecommissionErasureLayout,
|
|
pub physical_total_at_reservation: usize,
|
|
pub physical_free_at_reservation: usize,
|
|
pub reserved_physical_bytes: usize,
|
|
#[serde(default)]
|
|
pub consumed_physical_bytes: usize,
|
|
#[serde(default)]
|
|
pub observed_physical_bytes: usize,
|
|
#[serde(default)]
|
|
pub inflight_physical_bytes: usize,
|
|
#[serde(default)]
|
|
pub pending_physical_bytes: usize,
|
|
#[serde(default)]
|
|
pub pending_mutation_id: Option<uuid::Uuid>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub temporary_mutations: Vec<DecommissionCapacityTemporaryMutation>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct DecommissionCapacityTemporaryMutation {
|
|
pub mutation_id: uuid::Uuid,
|
|
pub physical_bytes: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct DecommissionCapacityReservation {
|
|
pub model_version: u16,
|
|
pub operation_id: uuid::Uuid,
|
|
pub generation: u64,
|
|
pub owner_nonce: uuid::Uuid,
|
|
pub source_pool_index: usize,
|
|
pub source_layout: DecommissionErasureLayout,
|
|
pub source_physical_total_bytes: usize,
|
|
pub source_physical_bytes: usize,
|
|
pub source_data_equivalent_bytes: usize,
|
|
pub predicted_physical_bytes: usize,
|
|
pub temporary_copies: usize,
|
|
pub temporary_physical_bytes: usize,
|
|
pub peak_physical_bytes: usize,
|
|
pub committed_data_bytes: usize,
|
|
#[serde(default)]
|
|
pub consumed_target_physical_bytes: usize,
|
|
#[serde(default)]
|
|
pub observed_target_physical_bytes: usize,
|
|
#[serde(default)]
|
|
pub inflight_target_physical_bytes: usize,
|
|
#[serde(default)]
|
|
pub pending_target_physical_bytes: usize,
|
|
pub prediction_error_bytes: i64,
|
|
pub targets: Vec<DecommissionCapacityTarget>,
|
|
#[serde(with = "time::serde::rfc3339")]
|
|
pub created_at: OffsetDateTime,
|
|
#[serde(with = "time::serde::rfc3339")]
|
|
pub renewed_at: OffsetDateTime,
|
|
#[serde(with = "time::serde::rfc3339")]
|
|
pub expires_at: OffsetDateTime,
|
|
#[serde(with = "time::serde::rfc3339::option", default)]
|
|
pub recovered_at: Option<OffsetDateTime>,
|
|
#[serde(with = "time::serde::rfc3339::option", default)]
|
|
pub released_at: Option<OffsetDateTime>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub release_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum DecommissionCapacityMutationMode {
|
|
Durable,
|
|
Temporary,
|
|
NonGrowingReplacement,
|
|
TemporaryRelease,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct DecommissionCapacityReleaseProof {
|
|
confirmed_absent: bool,
|
|
clear_pending: bool,
|
|
}
|
|
|
|
impl DecommissionCapacityReleaseProof {
|
|
const fn confirmed_absence(clear_pending: bool) -> Self {
|
|
Self {
|
|
confirmed_absent: true,
|
|
clear_pending,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct DecommissionCapacityOwner {
|
|
pub(crate) source_pool_index: usize,
|
|
pub(crate) operation_id: uuid::Uuid,
|
|
pub(crate) generation: u64,
|
|
pub(crate) owner_nonce: uuid::Uuid,
|
|
pub(crate) mutation_id: Option<uuid::Uuid>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
struct DecommissionCapacityTargetPermitKey {
|
|
store_id: uuid::Uuid,
|
|
target_pool_index: usize,
|
|
source_pool_index: usize,
|
|
operation_id: uuid::Uuid,
|
|
generation: u64,
|
|
owner_nonce: uuid::Uuid,
|
|
mutation_id: uuid::Uuid,
|
|
}
|
|
|
|
impl DecommissionCapacityTargetPermitKey {
|
|
fn new(store_id: uuid::Uuid, target_pool_index: usize, owner: DecommissionCapacityOwner) -> Option<Self> {
|
|
Some(Self {
|
|
store_id,
|
|
target_pool_index,
|
|
source_pool_index: owner.source_pool_index,
|
|
operation_id: owner.operation_id,
|
|
generation: owner.generation,
|
|
owner_nonce: owner.owner_nonce,
|
|
mutation_id: owner.mutation_id?,
|
|
})
|
|
}
|
|
|
|
fn owns_same_mutation(&self, store_id: uuid::Uuid, owner: DecommissionCapacityOwner) -> bool {
|
|
owner.mutation_id.is_some_and(|mutation_id| {
|
|
self.store_id == store_id
|
|
&& self.source_pool_index == owner.source_pool_index
|
|
&& self.operation_id == owner.operation_id
|
|
&& self.generation == owner.generation
|
|
&& self.owner_nonce == owner.owner_nonce
|
|
&& self.mutation_id == mutation_id
|
|
})
|
|
}
|
|
}
|
|
|
|
struct DecommissionCapacityTargetPermit {
|
|
key: DecommissionCapacityTargetPermitKey,
|
|
}
|
|
|
|
// Busy recovery crosses option rebuilding and a spawned migration task. Keep
|
|
// the distributed guard in one exact-mutation side table so target selection
|
|
// can pin that target and the formal capacity mutation can consume the guard.
|
|
static DECOMMISSION_CAPACITY_TARGET_PERMITS: std::sync::OnceLock<
|
|
std::sync::Mutex<HashMap<DecommissionCapacityTargetPermitKey, rustfs_lock::NamespaceLockGuard>>,
|
|
> = std::sync::OnceLock::new();
|
|
|
|
fn decommission_capacity_target_permits()
|
|
-> &'static std::sync::Mutex<HashMap<DecommissionCapacityTargetPermitKey, rustfs_lock::NamespaceLockGuard>> {
|
|
DECOMMISSION_CAPACITY_TARGET_PERMITS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
|
|
}
|
|
|
|
fn install_decommission_capacity_target_permit(
|
|
store_id: uuid::Uuid,
|
|
target_pool_index: usize,
|
|
owner: DecommissionCapacityOwner,
|
|
guard: rustfs_lock::NamespaceLockGuard,
|
|
) -> Result<DecommissionCapacityTargetPermit> {
|
|
let key = DecommissionCapacityTargetPermitKey::new(store_id, target_pool_index, owner)
|
|
.ok_or_else(|| Error::other("decommission target permit is missing its mutation identity"))?;
|
|
let mut permits = decommission_capacity_target_permits()
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if permits.keys().any(|candidate| candidate.owns_same_mutation(store_id, owner)) {
|
|
return Err(Error::other("decommission target permit already exists for this mutation"));
|
|
}
|
|
permits.insert(key, guard);
|
|
Ok(DecommissionCapacityTargetPermit { key })
|
|
}
|
|
|
|
fn take_decommission_capacity_target_permit(
|
|
store_id: uuid::Uuid,
|
|
target_pool_index: usize,
|
|
owner: DecommissionCapacityOwner,
|
|
) -> Option<rustfs_lock::NamespaceLockGuard> {
|
|
let key = DecommissionCapacityTargetPermitKey::new(store_id, target_pool_index, owner)?;
|
|
decommission_capacity_target_permits()
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.remove(&key)
|
|
}
|
|
|
|
fn decommission_capacity_target_permit_index(store_id: uuid::Uuid, owner: DecommissionCapacityOwner) -> Option<usize> {
|
|
decommission_capacity_target_permits()
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.keys()
|
|
.find(|key| key.owns_same_mutation(store_id, owner))
|
|
.map(|key| key.target_pool_index)
|
|
}
|
|
|
|
fn discard_decommission_capacity_target_permit_except(
|
|
store_id: uuid::Uuid,
|
|
owner: DecommissionCapacityOwner,
|
|
retained_target_pool_index: Option<usize>,
|
|
) {
|
|
let guard = {
|
|
let mut permits = decommission_capacity_target_permits()
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let key = permits
|
|
.keys()
|
|
.find(|key| key.owns_same_mutation(store_id, owner) && retained_target_pool_index != Some(key.target_pool_index))
|
|
.copied();
|
|
key.and_then(|key| permits.remove(&key))
|
|
};
|
|
drop(guard);
|
|
}
|
|
|
|
impl Drop for DecommissionCapacityTargetPermit {
|
|
fn drop(&mut self) {
|
|
let guard = decommission_capacity_target_permits()
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.remove(&self.key);
|
|
drop(guard);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct DecommissionCapacityTerminalFencePlan {
|
|
model_version: u16,
|
|
operation_id: uuid::Uuid,
|
|
generation: u64,
|
|
owner_nonce: uuid::Uuid,
|
|
target_pool_indices: Vec<usize>,
|
|
}
|
|
|
|
fn decommission_capacity_terminal_fence_plan(
|
|
meta: &PoolMeta,
|
|
source_pool_index: usize,
|
|
) -> Result<Option<DecommissionCapacityTerminalFencePlan>> {
|
|
let Some(reservation) = meta
|
|
.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| reservation.active())
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
if reservation.source_pool_index != source_pool_index {
|
|
return Err(Error::DecommissionCapacity(
|
|
"decommission terminal transition found a mismatched capacity source".to_string(),
|
|
));
|
|
}
|
|
if !matches!(
|
|
reservation.model_version,
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION | DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION
|
|
) {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"decommission terminal transition found unsupported capacity lock model {}",
|
|
reservation.model_version
|
|
)));
|
|
}
|
|
let mut target_pool_indices = reservation.targets.iter().map(|target| target.pool_index).collect::<Vec<_>>();
|
|
target_pool_indices.sort_unstable();
|
|
Ok(Some(DecommissionCapacityTerminalFencePlan {
|
|
model_version: reservation.model_version,
|
|
operation_id: reservation.operation_id,
|
|
generation: reservation.generation,
|
|
owner_nonce: reservation.owner_nonce,
|
|
target_pool_indices,
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct DecommissionDurableIlmCheckpointTarget {
|
|
pub(crate) source_pool_index: usize,
|
|
pub(crate) target_pool_index: usize,
|
|
pub(crate) capacity_owner: DecommissionCapacityOwner,
|
|
pub(crate) already_committed: bool,
|
|
pub(crate) target_etag: Option<String>,
|
|
}
|
|
|
|
impl DecommissionCapacityOwner {
|
|
pub(crate) fn apply_to(self, opts: &mut ObjectOptions) {
|
|
opts.src_pool_idx = self.source_pool_index;
|
|
let admission = opts
|
|
.decommission_capacity
|
|
.get_or_insert_with(|| Box::new(DecommissionCapacityOptions::default()));
|
|
admission.operation_id = Some(self.operation_id);
|
|
admission.generation = Some(self.generation);
|
|
admission.owner_nonce = Some(self.owner_nonce);
|
|
admission.mutation_id = self.mutation_id;
|
|
}
|
|
|
|
pub(crate) fn from_options(opts: &ObjectOptions) -> Option<Self> {
|
|
let capacity = opts.decommission_capacity.as_deref()?;
|
|
Some(Self {
|
|
source_pool_index: opts.src_pool_idx,
|
|
operation_id: capacity.operation_id?,
|
|
generation: capacity.generation?,
|
|
owner_nonce: capacity.owner_nonce?,
|
|
mutation_id: capacity.mutation_id,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn with_mutation_id(self, mutation_id: uuid::Uuid) -> Self {
|
|
Self {
|
|
mutation_id: Some(mutation_id),
|
|
..self
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn decommission_capacity_mutation_id(
|
|
owner: DecommissionCapacityOwner,
|
|
bucket: &str,
|
|
object: &str,
|
|
version_id: Option<&str>,
|
|
delete_marker: bool,
|
|
mod_time: Option<OffsetDateTime>,
|
|
) -> uuid::Uuid {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(owner.operation_id.as_bytes());
|
|
hasher.update(owner.generation.to_le_bytes());
|
|
hasher.update(owner.source_pool_index.to_le_bytes());
|
|
for value in [bucket, object] {
|
|
hasher.update((value.len() as u64).to_le_bytes());
|
|
hasher.update(value.as_bytes());
|
|
}
|
|
hasher.update([u8::from(delete_marker)]);
|
|
if let Some(version_id) = version_id {
|
|
hasher.update([1]);
|
|
hasher.update((version_id.len() as u64).to_le_bytes());
|
|
hasher.update(version_id.as_bytes());
|
|
} else {
|
|
hasher.update([0]);
|
|
}
|
|
if let Some(mod_time) = mod_time {
|
|
hasher.update([1]);
|
|
hasher.update(mod_time.unix_timestamp_nanos().to_le_bytes());
|
|
} else {
|
|
hasher.update([0]);
|
|
}
|
|
let digest = hasher.finalize();
|
|
let mut bytes = [0; 16];
|
|
bytes.copy_from_slice(&digest[..16]);
|
|
uuid::Uuid::from_bytes(bytes)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct ExactDeleteCapacityReconciliation {
|
|
source_pool_index: usize,
|
|
target_pool_index: usize,
|
|
mutation_id: uuid::Uuid,
|
|
expected_data_bytes: usize,
|
|
expected_target_physical_bytes: usize,
|
|
}
|
|
|
|
fn plan_exact_delete_capacity_reconciliations(
|
|
meta: &PoolMeta,
|
|
object: &str,
|
|
exact: &ObjectInfo,
|
|
) -> Result<Vec<ExactDeleteCapacityReconciliation>> {
|
|
let version_id = exact.version_id.map(|version_id| version_id.to_string());
|
|
let mut matches = Vec::new();
|
|
|
|
for (source_pool_index, pool) in meta.pools.iter().enumerate() {
|
|
let Some(reservation) = pool
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| reservation.active())
|
|
else {
|
|
continue;
|
|
};
|
|
let owner = DecommissionCapacityOwner {
|
|
source_pool_index,
|
|
operation_id: reservation.operation_id,
|
|
generation: reservation.generation,
|
|
owner_nonce: reservation.owner_nonce,
|
|
mutation_id: None,
|
|
};
|
|
let logical_mutation_id = decommission_capacity_mutation_id(
|
|
owner,
|
|
&exact.bucket,
|
|
&exact.name,
|
|
version_id.as_deref(),
|
|
exact.delete_marker,
|
|
exact.mod_time,
|
|
);
|
|
// Existing data-movement producers persist directory-key intents using
|
|
// either the logical name or its internal `__XLDIR__` representation.
|
|
// Accept both while retaining the exact persisted identity for CAS.
|
|
let internal_mutation_id = if object == exact.name {
|
|
logical_mutation_id
|
|
} else {
|
|
decommission_capacity_mutation_id(
|
|
owner,
|
|
&exact.bucket,
|
|
object,
|
|
version_id.as_deref(),
|
|
exact.delete_marker,
|
|
exact.mod_time,
|
|
)
|
|
};
|
|
let mut source_match = None;
|
|
|
|
for target in &reservation.targets {
|
|
if target.pending_physical_bytes == 0 {
|
|
continue;
|
|
}
|
|
let Some(pending_mutation_id) = target.pending_mutation_id else {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {source_pool_index} target pool {} has pending capacity without an object identity",
|
|
target.pool_index
|
|
)));
|
|
};
|
|
if pending_mutation_id != logical_mutation_id && pending_mutation_id != internal_mutation_id {
|
|
continue;
|
|
}
|
|
if source_match.is_some() {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {source_pool_index} has the same exact-delete capacity intent on multiple targets"
|
|
)));
|
|
}
|
|
source_match = Some((target.pool_index, target.layout, target.pending_physical_bytes, pending_mutation_id));
|
|
}
|
|
|
|
let Some((target_pool_index, target_layout, pending_physical_bytes, mutation_id)) = source_match else {
|
|
continue;
|
|
};
|
|
if exact.version_id.is_none() && exact.mod_time.is_none() {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"unversioned exact delete cannot identify pending capacity without a modification time",
|
|
));
|
|
}
|
|
let expected_data_bytes = if exact.delete_marker {
|
|
0
|
|
} else {
|
|
usize::try_from(exact.size).map_err(|_| {
|
|
decommission_capacity_blocked_error("exact delete cannot reconcile a negative or overflowing object size")
|
|
})?
|
|
};
|
|
let expected_target_physical_bytes = capacity_target_physical_bytes(expected_data_bytes.max(1), target_layout)?;
|
|
if pending_physical_bytes != expected_target_physical_bytes {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {source_pool_index} target pool {target_pool_index} pending capacity does not match the exact object size"
|
|
)));
|
|
}
|
|
let remaining_target_physical_bytes = reservation
|
|
.targets
|
|
.iter()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.map(|target| {
|
|
target.remaining_reserved_physical_bytes(reservation.temporary_copies)
|
|
/ 1usize.saturating_add(reservation.temporary_copies)
|
|
})
|
|
.unwrap_or_default();
|
|
let remaining_total_physical_bytes = reservation
|
|
.predicted_physical_bytes
|
|
.saturating_sub(reservation.consumed_target_physical_bytes);
|
|
let remaining_data_bytes = reservation
|
|
.source_data_equivalent_bytes
|
|
.saturating_sub(reservation.committed_data_bytes);
|
|
if expected_target_physical_bytes > remaining_target_physical_bytes
|
|
|| expected_target_physical_bytes > remaining_total_physical_bytes
|
|
|| expected_data_bytes > remaining_data_bytes
|
|
{
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {source_pool_index} target pool {target_pool_index} lacks reservation capacity for the exact object"
|
|
)));
|
|
}
|
|
matches.push(ExactDeleteCapacityReconciliation {
|
|
source_pool_index,
|
|
target_pool_index,
|
|
mutation_id,
|
|
expected_data_bytes,
|
|
expected_target_physical_bytes,
|
|
});
|
|
}
|
|
|
|
Ok(matches)
|
|
}
|
|
|
|
fn ensure_exact_delete_capacity_namespace_fences(opts: &ObjectOptions, bucket: &str, object: &str) -> Result<()> {
|
|
let object_fence = opts.namespace_lock_fence.as_ref().ok_or_else(|| {
|
|
decommission_capacity_blocked_error("exact delete capacity reconciliation requires an object namespace fence")
|
|
})?;
|
|
if object_fence.is_lock_lost() {
|
|
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
|
mode: "exact_delete_capacity_reconciliation",
|
|
bucket: bucket.to_string(),
|
|
object: decode_dir_object(object),
|
|
required: 1,
|
|
achieved: 0,
|
|
});
|
|
}
|
|
if opts
|
|
.bucket_lifecycle_lock_fence
|
|
.as_ref()
|
|
.is_some_and(crate::object_api::NamespaceLockFence::is_lock_lost)
|
|
{
|
|
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
|
mode: "exact_delete_capacity_bucket_generation",
|
|
bucket: bucket.to_string(),
|
|
object: decode_dir_object(object),
|
|
required: 1,
|
|
achieved: 0,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn ensure_decommission_capacity_mutation_id(bucket: &str, object: &str, opts: &mut ObjectOptions) {
|
|
if opts
|
|
.decommission_capacity
|
|
.as_deref()
|
|
.is_none_or(|capacity| capacity.mutation_id.is_some())
|
|
{
|
|
return;
|
|
}
|
|
let Some(owner) = DecommissionCapacityOwner::from_options(opts) else {
|
|
return;
|
|
};
|
|
let mutation_id =
|
|
decommission_capacity_mutation_id(owner, bucket, object, opts.version_id.as_deref(), opts.delete_marker, opts.mod_time);
|
|
if let Some(capacity) = opts.decommission_capacity.as_mut() {
|
|
capacity.mutation_id = Some(mutation_id);
|
|
}
|
|
}
|
|
|
|
impl DecommissionCapacityReservation {
|
|
fn active(&self) -> bool {
|
|
self.released_at.is_none()
|
|
}
|
|
|
|
fn lease_active_at(&self, now: OffsetDateTime) -> bool {
|
|
self.active() && self.expires_at > now
|
|
}
|
|
|
|
fn admits_owner(&self, owner: DecommissionCapacityOwner, now: OffsetDateTime) -> bool {
|
|
self.lease_active_at(now)
|
|
&& self.source_pool_index == owner.source_pool_index
|
|
&& self.operation_id == owner.operation_id
|
|
&& self.generation == owner.generation
|
|
&& self.owner_nonce == owner.owner_nonce
|
|
}
|
|
|
|
fn admits_cleanup_owner(&self, owner: DecommissionCapacityOwner) -> bool {
|
|
self.active()
|
|
&& self.source_pool_index == owner.source_pool_index
|
|
&& self.operation_id == owner.operation_id
|
|
&& self.generation == owner.generation
|
|
}
|
|
|
|
fn remaining_peak_physical_bytes(&self) -> usize {
|
|
self.predicted_physical_bytes
|
|
.saturating_sub(self.consumed_target_physical_bytes)
|
|
.saturating_mul(1usize.saturating_add(self.temporary_copies))
|
|
}
|
|
}
|
|
|
|
impl DecommissionCapacityTarget {
|
|
fn remaining_reserved_physical_bytes(&self, temporary_copies: usize) -> usize {
|
|
self.reserved_physical_bytes.saturating_sub(
|
|
self.consumed_physical_bytes
|
|
.saturating_mul(1usize.saturating_add(temporary_copies)),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct PoolDecommissionInfo {
|
|
#[serde(rename = "startTime", with = "time::serde::rfc3339::option")]
|
|
pub start_time: Option<OffsetDateTime>,
|
|
#[serde(rename = "startSize")]
|
|
pub start_size: usize,
|
|
#[serde(rename = "totalSize")]
|
|
pub total_size: usize,
|
|
#[serde(rename = "currentSize")]
|
|
pub current_size: usize,
|
|
#[serde(rename = "complete")]
|
|
pub complete: bool,
|
|
#[serde(rename = "failed")]
|
|
pub failed: bool,
|
|
#[serde(rename = "canceled")]
|
|
pub canceled: bool,
|
|
#[serde(skip)]
|
|
pub queued: bool,
|
|
|
|
#[serde(skip)]
|
|
pub queued_buckets: Vec<String>,
|
|
#[serde(skip)]
|
|
pub decommissioned_buckets: Vec<String>,
|
|
#[serde(skip)]
|
|
pub bucket: String,
|
|
#[serde(skip)]
|
|
pub prefix: String,
|
|
#[serde(skip)]
|
|
pub object: String,
|
|
#[serde(skip)]
|
|
pub stage: String,
|
|
|
|
#[serde(rename = "objectsDecommissioned")]
|
|
pub items_decommissioned: usize,
|
|
#[serde(rename = "objectsDecommissionedFailed")]
|
|
pub items_decommission_failed: usize,
|
|
#[serde(rename = "bytesDecommissioned")]
|
|
pub bytes_done: usize,
|
|
#[serde(rename = "bytesDecommissionedFailed")]
|
|
pub bytes_failed: usize,
|
|
#[serde(rename = "terminalReloadAttemptAt", with = "time::serde::rfc3339::option", default)]
|
|
pub terminal_reload_attempt_at: Option<OffsetDateTime>,
|
|
#[serde(rename = "terminalReloadFailures", default)]
|
|
pub terminal_reload_failures: Vec<String>,
|
|
#[serde(rename = "capacityReservation", default, skip_serializing_if = "Option::is_none")]
|
|
pub capacity_reservation: Option<DecommissionCapacityReservation>,
|
|
#[serde(rename = "capacityBlockedReason", default, skip_serializing_if = "Option::is_none")]
|
|
pub capacity_blocked_reason: Option<String>,
|
|
#[serde(skip)]
|
|
pub unresolved_entries: Vec<DecommissionUnresolvedEntry>,
|
|
#[serde(skip)]
|
|
pub progress_save_item_baseline: usize,
|
|
#[serde(skip)]
|
|
pub progress_save_retry_after: Option<OffsetDateTime>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct DecommissionProgressCheckpoint {
|
|
start_time: Option<OffsetDateTime>,
|
|
queued: bool,
|
|
counted_items: usize,
|
|
checkpoint_at: OffsetDateTime,
|
|
capacity_operation_id: Option<uuid::Uuid>,
|
|
capacity_owner_nonce: Option<uuid::Uuid>,
|
|
capacity_lease_expires_at: Option<OffsetDateTime>,
|
|
}
|
|
|
|
impl PoolDecommissionInfo {
|
|
pub fn has_decommission_state(&self) -> bool {
|
|
self.complete
|
|
|| self.failed
|
|
|| self.canceled
|
|
|| self.queued
|
|
|| self.start_time.is_some()
|
|
|| self.start_size > 0
|
|
|| !self.queued_buckets.is_empty()
|
|
|| !self.decommissioned_buckets.is_empty()
|
|
|| !self.bucket.is_empty()
|
|
|| !self.prefix.is_empty()
|
|
|| !self.object.is_empty()
|
|
|| !self.stage.is_empty()
|
|
|| self.items_decommissioned > 0
|
|
|| self.items_decommission_failed > 0
|
|
|| self.bytes_done > 0
|
|
|| self.bytes_failed > 0
|
|
|| self.terminal_reload_attempt_at.is_some()
|
|
|| !self.terminal_reload_failures.is_empty()
|
|
|| self.capacity_reservation.is_some()
|
|
|| self.capacity_blocked_reason.is_some()
|
|
|| !self.unresolved_entries.is_empty()
|
|
}
|
|
|
|
fn counted_items(&self) -> usize {
|
|
self.items_decommissioned.saturating_add(self.items_decommission_failed)
|
|
}
|
|
|
|
fn items_since_last_progress_save(&self) -> usize {
|
|
self.counted_items().saturating_sub(self.progress_save_item_baseline)
|
|
}
|
|
|
|
fn mark_progress_saved(&mut self) {
|
|
self.progress_save_item_baseline = self.counted_items();
|
|
self.progress_save_retry_after = None;
|
|
}
|
|
|
|
pub fn bucket_push(&mut self, bucket: &DecomBucketInfo) {
|
|
let bucket_key = bucket.to_string();
|
|
if self.is_bucket_decommissioned(&bucket_key) {
|
|
return;
|
|
}
|
|
|
|
for b in self.queued_buckets.iter() {
|
|
if b == &bucket_key {
|
|
return;
|
|
}
|
|
}
|
|
|
|
self.queued_buckets.push(bucket_key);
|
|
|
|
self.bucket = bucket.name.clone();
|
|
self.prefix = bucket.prefix.clone();
|
|
}
|
|
pub fn is_bucket_decommissioned(&self, bucket: &String) -> bool {
|
|
for b in self.decommissioned_buckets.iter() {
|
|
if b == bucket {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
pub fn bucket_pop(&mut self, bucket: &String) -> bool {
|
|
self.decommissioned_buckets.push(bucket.clone());
|
|
|
|
let mut found = None;
|
|
for (i, b) in self.queued_buckets.iter().enumerate() {
|
|
if b == bucket {
|
|
found = Some(i);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if let Some(i) = found {
|
|
self.queued_buckets.remove(i);
|
|
if &self.bucket == bucket {
|
|
self.bucket = "".to_owned();
|
|
self.prefix = "".to_owned();
|
|
self.object = "".to_owned();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct PoolSpaceInfo {
|
|
pub free: usize,
|
|
pub total: usize,
|
|
pub used: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct DecommissionPoolCapacityInfo {
|
|
pool_index: usize,
|
|
space: PoolSpaceInfo,
|
|
layout: DecommissionErasureLayout,
|
|
physical_free: usize,
|
|
physical_total: usize,
|
|
physical_used: usize,
|
|
}
|
|
|
|
impl DecommissionPoolCapacityInfo {
|
|
#[cfg(test)]
|
|
pub(crate) fn for_test(
|
|
pool_index: usize,
|
|
layout: DecommissionErasureLayout,
|
|
physical_free: usize,
|
|
physical_total: usize,
|
|
physical_used: usize,
|
|
) -> Self {
|
|
Self {
|
|
pool_index,
|
|
space: PoolSpaceInfo {
|
|
free: physical_free,
|
|
total: physical_total,
|
|
used: physical_used,
|
|
},
|
|
layout,
|
|
physical_free,
|
|
physical_total,
|
|
physical_used,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
type DecommissionCapacityInfoOverrides =
|
|
std::sync::Mutex<HashMap<uuid::Uuid, std::collections::VecDeque<Vec<DecommissionPoolCapacityInfo>>>>;
|
|
|
|
#[cfg(test)]
|
|
static DECOMMISSION_CAPACITY_INFO_OVERRIDES: std::sync::OnceLock<DecommissionCapacityInfoOverrides> = std::sync::OnceLock::new();
|
|
|
|
/// Queues capacity snapshots consumed in order by `get_decommission_all_pool_capacity_infos`;
|
|
/// the final snapshot is retained and replayed for every subsequent sample, so tests never
|
|
/// fall back to the host's real disk statistics once an override is installed.
|
|
#[cfg(test)]
|
|
pub(crate) fn set_decommission_capacity_info_overrides_for_test(
|
|
store_id: uuid::Uuid,
|
|
snapshots: Vec<Vec<DecommissionPoolCapacityInfo>>,
|
|
) {
|
|
DECOMMISSION_CAPACITY_INFO_OVERRIDES
|
|
.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
|
|
.lock()
|
|
.expect("decommission capacity info override should not be poisoned")
|
|
.insert(store_id, snapshots.into());
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn take_decommission_capacity_info_override_for_test(store_id: uuid::Uuid) -> Option<Vec<DecommissionPoolCapacityInfo>> {
|
|
let mut overrides = DECOMMISSION_CAPACITY_INFO_OVERRIDES
|
|
.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
|
|
.lock()
|
|
.expect("decommission capacity info override should not be poisoned");
|
|
let queue = overrides.get_mut(&store_id)?;
|
|
if queue.len() > 1 {
|
|
queue.pop_front()
|
|
} else {
|
|
// The final snapshot is replayed forever: extra sampling points added to
|
|
// the decommission paths must keep observing injected capacity instead of
|
|
// silently falling back to the host's real statfs numbers (see #6989).
|
|
queue.front().cloned()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
struct DecommissionCapacityLockOrderBarrierState {
|
|
owner_store_id: uuid::Uuid,
|
|
external_store_id: uuid::Uuid,
|
|
owner_arrived: tokio::sync::Notify,
|
|
owner_release: tokio::sync::Notify,
|
|
owner_pause_enabled: AtomicBool,
|
|
external_capacity_released: tokio::sync::Notify,
|
|
external_object_capacity_probe_acquired: tokio::sync::Notify,
|
|
external_object_capacity_probe_release: tokio::sync::Notify,
|
|
external_object_capacity_probe_paused: AtomicBool,
|
|
external_object_commit_phase_started: tokio::sync::Notify,
|
|
external_object_commit_phase_release: tokio::sync::Notify,
|
|
external_object_commit_phase_paused: AtomicBool,
|
|
external_heal_operation_started: tokio::sync::Notify,
|
|
external_heal_target_lock_attempted: tokio::sync::Notify,
|
|
target_gate_retry_entered: tokio::sync::Notify,
|
|
target_gate_retry_entries: AtomicUsize,
|
|
target_gate_exact_reloads: AtomicUsize,
|
|
target_gate_acquire_pause_target: AtomicUsize,
|
|
target_gate_acquire_entered: tokio::sync::Notify,
|
|
target_gate_acquire_release: tokio::sync::Notify,
|
|
cancel_before_start_entered: tokio::sync::Notify,
|
|
cancel_before_start_release: tokio::sync::Notify,
|
|
cancel_before_start_paused: AtomicBool,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) struct DecommissionCapacityLockOrderBarrier {
|
|
state: Arc<DecommissionCapacityLockOrderBarrierState>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
static DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER: std::sync::OnceLock<
|
|
std::sync::Mutex<Option<Arc<DecommissionCapacityLockOrderBarrierState>>>,
|
|
> = std::sync::OnceLock::new();
|
|
|
|
#[cfg(test)]
|
|
impl DecommissionCapacityLockOrderBarrier {
|
|
pub(crate) fn install(owner_store_id: uuid::Uuid, external_store_id: uuid::Uuid) -> Self {
|
|
let state = Arc::new(DecommissionCapacityLockOrderBarrierState {
|
|
owner_store_id,
|
|
external_store_id,
|
|
owner_arrived: tokio::sync::Notify::new(),
|
|
owner_release: tokio::sync::Notify::new(),
|
|
owner_pause_enabled: AtomicBool::new(true),
|
|
external_capacity_released: tokio::sync::Notify::new(),
|
|
external_object_capacity_probe_acquired: tokio::sync::Notify::new(),
|
|
external_object_capacity_probe_release: tokio::sync::Notify::new(),
|
|
external_object_capacity_probe_paused: AtomicBool::new(false),
|
|
external_object_commit_phase_started: tokio::sync::Notify::new(),
|
|
external_object_commit_phase_release: tokio::sync::Notify::new(),
|
|
external_object_commit_phase_paused: AtomicBool::new(false),
|
|
external_heal_operation_started: tokio::sync::Notify::new(),
|
|
external_heal_target_lock_attempted: tokio::sync::Notify::new(),
|
|
target_gate_retry_entered: tokio::sync::Notify::new(),
|
|
target_gate_retry_entries: AtomicUsize::new(0),
|
|
target_gate_exact_reloads: AtomicUsize::new(0),
|
|
target_gate_acquire_pause_target: AtomicUsize::new(usize::MAX),
|
|
target_gate_acquire_entered: tokio::sync::Notify::new(),
|
|
target_gate_acquire_release: tokio::sync::Notify::new(),
|
|
cancel_before_start_entered: tokio::sync::Notify::new(),
|
|
cancel_before_start_release: tokio::sync::Notify::new(),
|
|
cancel_before_start_paused: AtomicBool::new(false),
|
|
});
|
|
let mut slot = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned");
|
|
assert!(slot.is_none(), "decommission capacity lock-order barrier must be unique");
|
|
*slot = Some(Arc::clone(&state));
|
|
Self { state }
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) async fn wait_until_owner_paused(&self) {
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), self.state.owner_arrived.notified())
|
|
.await
|
|
.expect("owned capacity mutation should reach admission before its metadata write");
|
|
}
|
|
|
|
pub(crate) async fn wait_until_external_capacity_released(&self) {
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), self.state.external_capacity_released.notified())
|
|
.await
|
|
.expect("external mutation should release capacity before waiting for the object namespace");
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) async fn wait_until_external_object_capacity_probe_acquired(&self) {
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(30),
|
|
self.state.external_object_capacity_probe_acquired.notified(),
|
|
)
|
|
.await
|
|
.expect("external object mutation should acquire its no-active capacity probe");
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) async fn wait_until_external_object_commit_phase_started(&self) {
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(30),
|
|
self.state.external_object_commit_phase_started.notified(),
|
|
)
|
|
.await
|
|
.expect("external object mutation should reach its staged commit phase before waiting for the namespace");
|
|
}
|
|
|
|
pub(crate) async fn wait_until_external_heal_operation_started(&self) {
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), self.state.external_heal_operation_started.notified())
|
|
.await
|
|
.expect("external heal should reach the target operation after capacity admission");
|
|
}
|
|
|
|
pub(crate) async fn wait_until_external_heal_target_lock_attempted(&self) {
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(30),
|
|
self.state.external_heal_target_lock_attempted.notified(),
|
|
)
|
|
.await
|
|
.expect("external heal should attempt the target namespace lock after capacity admission");
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) async fn wait_until_target_gate_retry(&self) {
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), self.state.target_gate_retry_entered.notified())
|
|
.await
|
|
.expect("decommission entry should observe target gate contention");
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) async fn wait_until_target_gate_retries(&self, expected: usize) {
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
|
loop {
|
|
let notified = self.state.target_gate_retry_entered.notified();
|
|
if self.state.target_gate_retry_entries.load(Ordering::Acquire) >= expected {
|
|
return;
|
|
}
|
|
notified.await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("decommission entries should observe target gate contention");
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) fn target_gate_exact_reloads(&self) -> usize {
|
|
self.state.target_gate_exact_reloads.load(Ordering::Acquire)
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) fn pause_target_gate_acquire(&self, target_pool_index: usize) {
|
|
self.state
|
|
.target_gate_acquire_pause_target
|
|
.store(target_pool_index, Ordering::Release);
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) async fn wait_until_target_gate_acquire_paused(&self) {
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), self.state.target_gate_acquire_entered.notified())
|
|
.await
|
|
.expect("decommission capacity mutation should pause before acquiring its target gate");
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) fn release_target_gate_acquire(&self) {
|
|
self.state
|
|
.target_gate_acquire_pause_target
|
|
.store(usize::MAX, Ordering::Release);
|
|
self.state.target_gate_acquire_release.notify_one();
|
|
}
|
|
|
|
pub(crate) fn pause_cancel_before_start(&self) {
|
|
self.state.cancel_before_start_paused.store(true, Ordering::Release);
|
|
}
|
|
|
|
pub(crate) async fn wait_until_cancel_before_start(&self) {
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), self.state.cancel_before_start_entered.notified())
|
|
.await
|
|
.expect("decommission cancel should pause before acquiring the start gate");
|
|
}
|
|
|
|
pub(crate) fn release_cancel_before_start(&self) {
|
|
self.state.cancel_before_start_release.notify_one();
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) fn release_owner(&self) {
|
|
self.state.owner_release.notify_one();
|
|
self.state.target_gate_acquire_release.notify_one();
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) fn disable_owner_pause(&self) {
|
|
self.state.owner_pause_enabled.store(false, Ordering::Release);
|
|
self.state.owner_release.notify_waiters();
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) fn pause_external_object_commit_phase(&self) {
|
|
self.state.external_object_commit_phase_paused.store(true, Ordering::Release);
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) fn release_external_object_commit_phase(&self) {
|
|
self.state.external_object_commit_phase_release.notify_one();
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) fn pause_external_object_capacity_probe(&self) {
|
|
self.state
|
|
.external_object_capacity_probe_paused
|
|
.store(true, Ordering::Release);
|
|
}
|
|
|
|
#[cfg(feature = "test-util")]
|
|
pub(crate) fn release_external_object_capacity_probe(&self) {
|
|
self.state.external_object_capacity_probe_release.notify_one();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Drop for DecommissionCapacityLockOrderBarrier {
|
|
fn drop(&mut self) {
|
|
self.state.owner_release.notify_one();
|
|
self.state.external_object_capacity_probe_release.notify_one();
|
|
self.state.external_object_commit_phase_release.notify_one();
|
|
self.state.cancel_before_start_release.notify_one();
|
|
let mut slot = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned");
|
|
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
|
*slot = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn pause_decommission_capacity_before_owner_write(store_id: uuid::Uuid) {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|state| state.owner_store_id == store_id && state.owner_pause_enabled.load(Ordering::Acquire))
|
|
.cloned();
|
|
if let Some(barrier) = barrier {
|
|
barrier.owner_arrived.notify_one();
|
|
barrier.owner_release.notified().await;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn pause_decommission_capacity_before_target_gate_acquire(store_id: uuid::Uuid, target_pool_index: usize) {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|state| {
|
|
state.owner_store_id == store_id
|
|
&& state.target_gate_acquire_pause_target.load(Ordering::Acquire) == target_pool_index
|
|
})
|
|
.cloned();
|
|
if let Some(barrier) = barrier {
|
|
barrier.target_gate_acquire_entered.notify_one();
|
|
barrier.target_gate_acquire_release.notified().await;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn pause_decommission_cancel_before_start_gate(store_id: uuid::Uuid) {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|state| state.owner_store_id == store_id && state.cancel_before_start_paused.load(Ordering::Acquire))
|
|
.cloned();
|
|
if let Some(barrier) = barrier {
|
|
barrier.cancel_before_start_entered.notify_one();
|
|
barrier.cancel_before_start_release.notified().await;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn notify_decommission_target_gate_retry(store_id: uuid::Uuid) {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|state| state.owner_store_id == store_id)
|
|
.cloned();
|
|
if let Some(barrier) = barrier {
|
|
barrier.target_gate_retry_entries.fetch_add(1, Ordering::AcqRel);
|
|
barrier.target_gate_retry_entered.notify_one();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn notify_decommission_target_gate_exact_reload(store_id: uuid::Uuid) {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|state| state.owner_store_id == store_id)
|
|
.cloned();
|
|
if let Some(barrier) = barrier {
|
|
barrier.target_gate_exact_reloads.fetch_add(1, Ordering::AcqRel);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn notify_decommission_external_object_capacity_released(store_id: uuid::Uuid) {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|state| state.external_store_id == store_id)
|
|
.cloned();
|
|
if let Some(barrier) = barrier {
|
|
barrier.external_capacity_released.notify_one();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn notify_decommission_external_object_capacity_probe_acquired(store_id: uuid::Uuid) {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|state| state.external_store_id == store_id)
|
|
.cloned();
|
|
if let Some(barrier) = barrier {
|
|
barrier.external_object_capacity_probe_acquired.notify_one();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn wait_for_decommission_external_object_capacity_probe_release(store_id: uuid::Uuid) {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|state| state.external_store_id == store_id)
|
|
.cloned();
|
|
if let Some(barrier) = barrier
|
|
&& barrier.external_object_capacity_probe_paused.load(Ordering::Acquire)
|
|
{
|
|
barrier.external_object_capacity_probe_release.notified().await;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn notify_decommission_external_object_commit_phase_started(store_id: uuid::Uuid) {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|state| state.external_store_id == store_id)
|
|
.cloned();
|
|
if let Some(barrier) = barrier {
|
|
barrier.external_object_commit_phase_started.notify_one();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn wait_for_decommission_external_object_commit_phase_release(store_id: uuid::Uuid) {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|state| state.external_store_id == store_id)
|
|
.cloned();
|
|
if let Some(barrier) = barrier
|
|
&& barrier.external_object_commit_phase_paused.load(Ordering::Acquire)
|
|
{
|
|
barrier.external_object_commit_phase_release.notified().await;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn notify_decommission_external_heal_operation_started(store_id: uuid::Uuid) {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|state| state.external_store_id == store_id)
|
|
.cloned();
|
|
if let Some(barrier) = barrier {
|
|
barrier.external_heal_operation_started.notify_one();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn notify_decommission_external_heal_target_lock_attempted() {
|
|
let barrier = DECOMMISSION_CAPACITY_LOCK_ORDER_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("decommission capacity lock-order barrier should not be poisoned")
|
|
.as_ref()
|
|
.cloned();
|
|
if let Some(barrier) = barrier {
|
|
barrier.external_heal_target_lock_attempted.notify_one();
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct DecomBucketInfo {
|
|
pub name: String,
|
|
pub prefix: String,
|
|
}
|
|
|
|
impl Display for DecomBucketInfo {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(
|
|
f,
|
|
"{}",
|
|
path_join(&[PathBuf::from(self.name.clone()), PathBuf::from(self.prefix.clone())]).to_string_lossy()
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum DecommissionFinalState {
|
|
Complete,
|
|
Failed,
|
|
}
|
|
|
|
fn determine_decommission_final_state(items_failed: usize, was_cancelled: bool) -> DecommissionFinalState {
|
|
if items_failed > 0 || was_cancelled {
|
|
DecommissionFinalState::Failed
|
|
} else {
|
|
DecommissionFinalState::Complete
|
|
}
|
|
}
|
|
|
|
fn decommission_remaining_version_count(versions: &[rustfs_filemeta::FileInfo], expired: usize) -> usize {
|
|
versions
|
|
.iter()
|
|
.filter(|version| !version.tier_free_version())
|
|
.count()
|
|
.saturating_sub(expired)
|
|
}
|
|
|
|
fn should_skip_decommission_delete_marker(
|
|
version: &rustfs_filemeta::FileInfo,
|
|
remaining_versions: usize,
|
|
replication_configured: bool,
|
|
) -> bool {
|
|
// Match MinIO decommission behavior: an empty delete marker is not moved to
|
|
// another pool unless replication is configured and its marker state matters.
|
|
version.deleted && remaining_versions == 1 && !replication_configured
|
|
}
|
|
|
|
fn decommission_delete_marker_opts(
|
|
version: &rustfs_filemeta::FileInfo,
|
|
version_id: Option<String>,
|
|
src_pool_idx: usize,
|
|
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
|
) -> ObjectOptions {
|
|
let version_suspended = version.version_id.is_none() && version_id.is_none();
|
|
ObjectOptions {
|
|
versioned: !version_suspended,
|
|
version_suspended,
|
|
version_id: version_id.or_else(|| version_suspended.then(|| uuid::Uuid::nil().to_string())),
|
|
mod_time: version.mod_time,
|
|
src_pool_idx,
|
|
data_movement: true,
|
|
delete_marker: true,
|
|
skip_decommissioned: true,
|
|
expected_bucket_incarnation_id,
|
|
delete_replication: version
|
|
.replication_state_internal
|
|
.as_ref()
|
|
.map(replication_state_from_filemeta),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn decommission_object_migration_read_opts(version_id: Option<String>) -> ObjectOptions {
|
|
ObjectOptions {
|
|
version_id,
|
|
no_lock: true,
|
|
data_movement: true,
|
|
raw_data_movement_read: true,
|
|
skip_decommissioned: true,
|
|
skip_rebalancing: true,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn decommission_remote_tiered_opts(
|
|
version: &rustfs_filemeta::FileInfo,
|
|
version_id: Option<String>,
|
|
src_pool_idx: usize,
|
|
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
|
) -> ObjectOptions {
|
|
ObjectOptions {
|
|
versioned: version_id.is_some(),
|
|
version_id,
|
|
mod_time: version.mod_time,
|
|
user_defined: version.metadata.clone(),
|
|
src_pool_idx,
|
|
data_movement: true,
|
|
incl_free_versions: version.tier_free_version(),
|
|
include_part_checksums: true,
|
|
http_preconditions: Some(data_movement::data_movement_target_precondition()),
|
|
expected_bucket_incarnation_id,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn decommission_capacity_version_mutation_id(
|
|
owner: DecommissionCapacityOwner,
|
|
bucket: &str,
|
|
version: &rustfs_filemeta::FileInfo,
|
|
) -> uuid::Uuid {
|
|
let version_id = if version.deleted && version.version_id.is_none() {
|
|
Some(uuid::Uuid::nil().to_string())
|
|
} else {
|
|
version.version_id.map(|version_id| version_id.to_string())
|
|
};
|
|
decommission_capacity_mutation_id(owner, bucket, &version.name, version_id.as_deref(), version.deleted, version.mod_time)
|
|
}
|
|
|
|
fn decommission_capacity_owned_opts(mut opts: ObjectOptions, capacity_owner: Option<DecommissionCapacityOwner>) -> ObjectOptions {
|
|
if let Some(capacity_owner) = capacity_owner {
|
|
capacity_owner.apply_to(&mut opts);
|
|
}
|
|
opts
|
|
}
|
|
|
|
fn lifecycle_action_removes_data_movement_version(action: IlmAction) -> bool {
|
|
matches!(
|
|
action,
|
|
IlmAction::DeleteVersionAction | IlmAction::DeleteAllVersionsAction | IlmAction::DelMarkerDeleteAllVersionsAction
|
|
)
|
|
}
|
|
|
|
fn lifecycle_action_skips_heal_version(action: IlmAction) -> bool {
|
|
action.delete()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
struct LifecycleDataMovementMutationBarrierState {
|
|
bucket: String,
|
|
object: String,
|
|
arrived: tokio::sync::Notify,
|
|
release: tokio::sync::Notify,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) struct LifecycleDataMovementMutationBarrier {
|
|
state: Arc<LifecycleDataMovementMutationBarrierState>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
static LIFECYCLE_DATA_MOVEMENT_MUTATION_BARRIER: std::sync::OnceLock<
|
|
std::sync::Mutex<Option<Arc<LifecycleDataMovementMutationBarrierState>>>,
|
|
> = std::sync::OnceLock::new();
|
|
|
|
#[cfg(test)]
|
|
impl LifecycleDataMovementMutationBarrier {
|
|
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
|
let state = Arc::new(LifecycleDataMovementMutationBarrierState {
|
|
bucket: bucket.to_string(),
|
|
object: object.to_string(),
|
|
arrived: tokio::sync::Notify::new(),
|
|
release: tokio::sync::Notify::new(),
|
|
});
|
|
let mut slot = LIFECYCLE_DATA_MOVEMENT_MUTATION_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("lifecycle data movement mutation barrier should not be poisoned");
|
|
assert!(slot.is_none(), "lifecycle data movement mutation barrier must be unique");
|
|
*slot = Some(Arc::clone(&state));
|
|
Self { state }
|
|
}
|
|
|
|
pub(crate) async fn wait_until_paused(&self) {
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), self.state.arrived.notified())
|
|
.await
|
|
.expect("lifecycle data movement should reach its mutation boundary");
|
|
}
|
|
|
|
pub(crate) fn release(&self) {
|
|
self.state.release.notify_one();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Drop for LifecycleDataMovementMutationBarrier {
|
|
fn drop(&mut self) {
|
|
self.state.release.notify_one();
|
|
let mut slot = LIFECYCLE_DATA_MOVEMENT_MUTATION_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("lifecycle data movement mutation barrier should not be poisoned");
|
|
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
|
*slot = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn pause_lifecycle_data_movement_mutation(bucket: &str, object: &str, has_run_fence_signal: bool) {
|
|
if !has_run_fence_signal {
|
|
return;
|
|
}
|
|
let barrier = LIFECYCLE_DATA_MOVEMENT_MUTATION_BARRIER
|
|
.get_or_init(|| std::sync::Mutex::new(None))
|
|
.lock()
|
|
.expect("lifecycle data movement mutation barrier should not be poisoned")
|
|
.as_ref()
|
|
.filter(|barrier| barrier.bucket == bucket && barrier.object == object)
|
|
.cloned();
|
|
if let Some(barrier) = barrier {
|
|
barrier.arrived.notify_one();
|
|
barrier.release.notified().await;
|
|
}
|
|
}
|
|
|
|
fn resolve_data_movement_lifecycle_expiry_result(action: IlmAction, apply_actions: bool, applied: bool) -> Result<bool> {
|
|
if !apply_actions || applied {
|
|
return Ok(true);
|
|
}
|
|
|
|
Err(Error::other(format!(
|
|
"failed to apply lifecycle expiry action {action:?} during data movement"
|
|
)))
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn should_skip_lifecycle_for_data_movement(
|
|
store: Arc<ECStore>,
|
|
bucket: &str,
|
|
version: &rustfs_filemeta::FileInfo,
|
|
lifecycle_config: Option<&BucketLifecycleConfiguration>,
|
|
object_lock_config: Option<&ObjectLockConfiguration>,
|
|
apply_actions: bool,
|
|
event_source: &LcEventSrc,
|
|
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
|
) -> Result<bool> {
|
|
let Some(lifecycle_config) = lifecycle_config else {
|
|
return Ok(false);
|
|
};
|
|
|
|
let versioned = match BucketVersioningSys::get_in(&store.ctx, bucket).await {
|
|
Ok(config) => config.prefix_enabled(&version.name),
|
|
Err(err) => {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "versioning_config_unavailable",
|
|
bucket = %bucket,
|
|
error = %err,
|
|
"Decommission lifecycle versioning config unavailable; treating object as unversioned"
|
|
);
|
|
false
|
|
}
|
|
};
|
|
let object_info = crate::object_api::ObjectInfo::from_file_info(version, bucket, &version.name, versioned);
|
|
let event = eval_action_from_lifecycle(lifecycle_config, object_lock_config, &object_info).await;
|
|
|
|
match event.action {
|
|
IlmAction::DeleteRestoredAction | IlmAction::DeleteRestoredVersionAction => {
|
|
if apply_actions && object_info.is_remote() {
|
|
let Ok(bucket_incarnation_id) = store.bucket_incarnation_id_from_disk(bucket).await else {
|
|
return Ok(false);
|
|
};
|
|
let _ = match lock_lost_signal {
|
|
Some(signal) => {
|
|
apply_expiry_rule_for_data_movement(store, &event, event_source, &object_info, Some(signal)).await
|
|
}
|
|
None => {
|
|
apply_expiry_on_transitioned_object(store, &object_info, &event, event_source, bucket_incarnation_id)
|
|
.await
|
|
}
|
|
};
|
|
}
|
|
Ok(false)
|
|
}
|
|
action if lifecycle_action_removes_data_movement_version(action) => {
|
|
#[cfg(test)]
|
|
pause_lifecycle_data_movement_mutation(bucket, &version.name, lock_lost_signal.is_some()).await;
|
|
if lifecycle_delete_all_versions_blocked_by_replication(store.clone(), bucket, &object_info.name, action).await? {
|
|
return Ok(false);
|
|
}
|
|
let applied = !apply_actions
|
|
|| match lock_lost_signal {
|
|
Some(signal) => {
|
|
apply_expiry_rule_for_data_movement(store, &event, event_source, &object_info, Some(signal)).await
|
|
}
|
|
None => apply_expiry_rule_in(store, &event, event_source, &object_info).await,
|
|
};
|
|
resolve_data_movement_lifecycle_expiry_result(action, apply_actions, applied)
|
|
}
|
|
_ => Ok(false),
|
|
}
|
|
}
|
|
|
|
pub struct HealLifecycleExpiryContext {
|
|
configs: LifecycleExpiryConfigs,
|
|
}
|
|
|
|
impl ECStore {
|
|
pub async fn load_heal_lifecycle_expiry_context(&self, bucket: &str) -> Result<Option<HealLifecycleExpiryContext>> {
|
|
if bucket == RUSTFS_META_BUCKET {
|
|
return Ok(None);
|
|
}
|
|
|
|
let configs = get_expiry_configs(self, bucket).await?;
|
|
if configs.lifecycle.is_none() {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(HealLifecycleExpiryContext { configs }))
|
|
}
|
|
|
|
pub async fn enqueue_heal_lifecycle_expiry(
|
|
self: &Arc<Self>,
|
|
context: &HealLifecycleExpiryContext,
|
|
bucket: &str,
|
|
object: &str,
|
|
version_id: Option<&str>,
|
|
object_info: Option<&crate::object_api::ObjectInfo>,
|
|
) -> Result<bool> {
|
|
let Some(lifecycle_config) = context.configs.lifecycle.as_ref() else {
|
|
return Ok(false);
|
|
};
|
|
|
|
let object_info = if let Some(object_info) = object_info {
|
|
if object_info.bucket != bucket || object_info.name != object {
|
|
return Ok(false);
|
|
}
|
|
let snapshot_version_id = object_info
|
|
.version_id
|
|
.filter(|version_id| !version_id.is_nil())
|
|
.map(|version_id| version_id.to_string());
|
|
if snapshot_version_id.as_deref() != version_id {
|
|
return Ok(false);
|
|
}
|
|
object_info.clone()
|
|
} else {
|
|
match self
|
|
.get_object_info(
|
|
bucket,
|
|
object,
|
|
&ObjectOptions {
|
|
version_id: version_id.map(str::to_string),
|
|
versioned: version_id.is_some(),
|
|
expected_bucket_incarnation_id: Some(context.configs.bucket_incarnation_id),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(object_info) => object_info,
|
|
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => return Ok(false),
|
|
Err(err) => return Err(err),
|
|
}
|
|
};
|
|
|
|
let event = eval_action_from_lifecycle(lifecycle_config, context.configs.object_lock.as_deref(), &object_info).await;
|
|
if !lifecycle_action_skips_heal_version(event.action) {
|
|
return Ok(false);
|
|
}
|
|
|
|
if lifecycle_delete_all_versions_blocked_by_replication(self.clone(), bucket, &object_info.name, event.action).await? {
|
|
return Ok(false);
|
|
}
|
|
|
|
Ok(apply_expiry_rule_in(self.clone(), &event, &LcEventSrc::Scanner, &object_info).await)
|
|
}
|
|
|
|
async fn acquire_pool_meta_write_guard(
|
|
&self,
|
|
write_state: &mut PoolMetaWriteState,
|
|
operation: &str,
|
|
) -> Result<(rustfs_lock::NamespaceLockGuard, PoolMeta)> {
|
|
self.acquire_pool_meta_write_guard_with_lock_error(write_state, operation, activation_pool_meta_lock_error)
|
|
.await
|
|
}
|
|
|
|
async fn acquire_pool_meta_write_guard_with_lock_error<F>(
|
|
&self,
|
|
write_state: &mut PoolMetaWriteState,
|
|
operation: &str,
|
|
map_lock_error: F,
|
|
) -> Result<(rustfs_lock::NamespaceLockGuard, PoolMeta)>
|
|
where
|
|
F: FnOnce(rustfs_lock::LockError) -> Error,
|
|
{
|
|
write_state.ensure_write_safe(operation)?;
|
|
load_pool_meta_identity_observing(self.pools.clone(), write_state).await?;
|
|
let pool = self.pools.first().cloned().ok_or_else(|| {
|
|
Error::InvalidArgument(
|
|
operation.to_string(),
|
|
"storage-pools".to_string(),
|
|
"no storage pools available".to_string(),
|
|
)
|
|
})?;
|
|
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
|
|
let pool_meta_guard = pool_meta_lock
|
|
.get_write_lock(get_lock_acquire_timeout())
|
|
.await
|
|
.map_err(map_lock_error)?;
|
|
let selection = load_pool_meta_replicas_observing(self.pools.clone(), true, write_state).await?;
|
|
write_state.observe_replicas(selection.replica_state);
|
|
write_state.ensure_write_safe(operation)?;
|
|
Ok((pool_meta_guard, selection.meta))
|
|
}
|
|
|
|
async fn acquire_pool_meta_read_guard(
|
|
&self,
|
|
write_state: &mut PoolMetaWriteState,
|
|
operation: &str,
|
|
) -> Result<(rustfs_lock::NamespaceLockGuard, PoolMeta)> {
|
|
write_state.ensure_write_safe(operation)?;
|
|
let pool = self.pools.first().cloned().ok_or_else(|| {
|
|
Error::InvalidArgument(
|
|
operation.to_string(),
|
|
"storage-pools".to_string(),
|
|
"no storage pools available".to_string(),
|
|
)
|
|
})?;
|
|
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
|
|
let pool_meta_guard = pool_meta_lock.get_read_lock(get_lock_acquire_timeout()).await?;
|
|
let selection = load_pool_meta_replicas_observing(self.pools.clone(), true, write_state).await?;
|
|
write_state.observe_replicas(selection.replica_state);
|
|
write_state.ensure_write_safe(operation)?;
|
|
Ok((pool_meta_guard, selection.meta))
|
|
}
|
|
|
|
async fn acquire_decommission_capacity_target_guard(
|
|
&self,
|
|
target_pool_index: usize,
|
|
) -> Result<rustfs_lock::NamespaceLockGuard> {
|
|
// Some reconciliation callers already hold an object lock, while a
|
|
// target mutation acquires its object lock after this gate. A short,
|
|
// retryable acquisition bounds that inverse-order overlap.
|
|
let pool = self.pools.first().cloned().ok_or_else(|| {
|
|
Error::InvalidArgument(
|
|
"decommission-capacity".to_string(),
|
|
"storage-pools".to_string(),
|
|
"no storage pools available".to_string(),
|
|
)
|
|
})?;
|
|
let object = format!("{DECOMMISSION_CAPACITY_TARGET_LOCK_PREFIX}/{target_pool_index}");
|
|
let target_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, &object).await?;
|
|
#[cfg(test)]
|
|
pause_decommission_capacity_before_target_gate_acquire(self.id, target_pool_index).await;
|
|
match target_lock
|
|
.get_write_lock_quiet(DECOMMISSION_CAPACITY_TARGET_LOCK_TIMEOUT)
|
|
.await
|
|
{
|
|
Ok(guard) => Ok(guard),
|
|
Err(rustfs_lock::LockError::Timeout { .. } | rustfs_lock::LockError::AlreadyLocked { .. }) => {
|
|
Err(decommission_capacity_blocked_error(format!(
|
|
"{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_PREFIX}{target_pool_index}{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_SUFFIX}"
|
|
)))
|
|
}
|
|
Err(rustfs_lock::LockError::QuorumNotReached { required, achieved }) => Err(Error::NamespaceLockQuorumUnavailable {
|
|
mode: "write",
|
|
bucket: RUSTFS_META_BUCKET.to_string(),
|
|
object,
|
|
required,
|
|
achieved,
|
|
}),
|
|
Err(err) => Err(Error::Lock(err)),
|
|
}
|
|
}
|
|
|
|
async fn acquire_decommission_capacity_owner_target_guard(
|
|
&self,
|
|
owner: DecommissionCapacityOwner,
|
|
target_pool_index: usize,
|
|
) -> Result<Option<rustfs_lock::NamespaceLockGuard>> {
|
|
let model_version = self
|
|
.pool_meta
|
|
.read()
|
|
.await
|
|
.pools
|
|
.get(owner.source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| {
|
|
reservation.active()
|
|
&& reservation.source_pool_index == owner.source_pool_index
|
|
&& reservation.operation_id == owner.operation_id
|
|
&& reservation.generation == owner.generation
|
|
})
|
|
.map(|reservation| reservation.model_version)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("decommission capacity owner is stale"))?;
|
|
match model_version {
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION => Ok(None),
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION => {
|
|
discard_decommission_capacity_target_permit_except(self.id, owner, Some(target_pool_index));
|
|
match take_decommission_capacity_target_permit(self.id, target_pool_index, owner) {
|
|
Some(guard) => Ok(Some(guard)),
|
|
None => self
|
|
.acquire_decommission_capacity_target_guard(target_pool_index)
|
|
.await
|
|
.map(Some),
|
|
}
|
|
}
|
|
version => Err(Error::DecommissionCapacity(format!(
|
|
"decommission capacity owner uses unsupported lock model {version}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
async fn acquire_decommission_capacity_terminal_guards(
|
|
&self,
|
|
plan: Option<&DecommissionCapacityTerminalFencePlan>,
|
|
) -> Result<Vec<rustfs_lock::NamespaceLockGuard>> {
|
|
let Some(plan) = plan else {
|
|
return Ok(Vec::new());
|
|
};
|
|
match plan.model_version {
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION => return Ok(Vec::new()),
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION => {}
|
|
version => {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"decommission terminal transition found unsupported capacity lock model {version}"
|
|
)));
|
|
}
|
|
}
|
|
// Terminal transitions hold no object or pool metadata lock here, so
|
|
// they can wait in target-index order without forming a lock cycle.
|
|
let pool = self.pools.first().cloned().ok_or_else(|| {
|
|
Error::InvalidArgument(
|
|
"decommission-capacity".to_string(),
|
|
"storage-pools".to_string(),
|
|
"no storage pools available".to_string(),
|
|
)
|
|
})?;
|
|
let mut guards = Vec::with_capacity(plan.target_pool_indices.len());
|
|
for &target_pool_index in &plan.target_pool_indices {
|
|
let object = format!("{DECOMMISSION_CAPACITY_TARGET_LOCK_PREFIX}/{target_pool_index}");
|
|
let target_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, &object).await?;
|
|
let guard = target_lock
|
|
.get_write_lock(get_lock_acquire_timeout())
|
|
.await
|
|
.map_err(|err| match err {
|
|
rustfs_lock::LockError::QuorumNotReached { required, achieved } => Error::NamespaceLockQuorumUnavailable {
|
|
mode: "write",
|
|
bucket: RUSTFS_META_BUCKET.to_string(),
|
|
object,
|
|
required,
|
|
achieved,
|
|
},
|
|
other => Error::Lock(other),
|
|
})?;
|
|
guards.push(guard);
|
|
}
|
|
Ok(guards)
|
|
}
|
|
|
|
pub(crate) async fn acquire_external_decommission_capacity_fence(
|
|
&self,
|
|
target_pool_indices: &[usize],
|
|
phase: &'static str,
|
|
) -> Result<rustfs_lock::NamespaceLockGuard> {
|
|
Ok(self
|
|
.acquire_external_decommission_capacity_fence_with_active_source(target_pool_indices, phase)
|
|
.await?
|
|
.0)
|
|
}
|
|
|
|
pub(crate) async fn acquire_external_decommission_capacity_fence_with_active_source(
|
|
&self,
|
|
target_pool_indices: &[usize],
|
|
phase: &'static str,
|
|
) -> Result<(rustfs_lock::NamespaceLockGuard, bool)> {
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (pool_meta_guard, snapshot) = self
|
|
.acquire_pool_meta_read_guard(&mut save_guard, "target capacity admission failed")
|
|
.await?;
|
|
for target_pool_index in target_pool_indices.iter().copied() {
|
|
ensure_external_decommission_target_admission(&snapshot, target_pool_index, phase)?;
|
|
}
|
|
let has_active_source = pool_meta_has_active_decommission(&snapshot);
|
|
drop(save_guard);
|
|
Ok((pool_meta_guard, has_active_source))
|
|
}
|
|
|
|
/// Fence healing of the pool metadata object itself without recursively
|
|
/// acquiring its namespace lock through the ordinary capacity probe. The
|
|
/// caller must retain the returned write guard through every admitted
|
|
/// repair; admission results preserve the input target order.
|
|
pub(crate) async fn acquire_pool_meta_object_heal_fence(
|
|
&self,
|
|
target_pool_indices: &[usize],
|
|
) -> Result<(rustfs_lock::NamespaceLockGuard, Vec<Result<()>>)> {
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (pool_meta_guard, snapshot) = self
|
|
.acquire_pool_meta_write_guard_with_lock_error(&mut save_guard, "pool metadata heal admission failed", Error::from)
|
|
.await?;
|
|
let admissions = target_pool_indices
|
|
.iter()
|
|
.copied()
|
|
.map(|target_pool_index| ensure_external_decommission_target_admission(&snapshot, target_pool_index, "heal"))
|
|
.collect();
|
|
drop(save_guard);
|
|
Ok((pool_meta_guard, admissions))
|
|
}
|
|
|
|
pub(crate) async fn acquire_decommission_capacity_release_fence_with_active_source(
|
|
&self,
|
|
) -> Result<(rustfs_lock::NamespaceLockGuard, bool)> {
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (pool_meta_guard, snapshot) = self
|
|
.acquire_pool_meta_read_guard(&mut save_guard, "capacity release fence failed")
|
|
.await?;
|
|
let has_active_source = pool_meta_has_active_decommission(&snapshot);
|
|
drop(save_guard);
|
|
Ok((pool_meta_guard, has_active_source))
|
|
}
|
|
|
|
pub(crate) async fn run_decommission_capacity_admitted_mutation<T, F, Fut>(
|
|
&self,
|
|
target_pool_index: usize,
|
|
capacity_owner: Option<DecommissionCapacityOwner>,
|
|
expected_data_bytes: Option<usize>,
|
|
operation: F,
|
|
) -> Result<T>
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: std::future::Future<Output = Result<T>>,
|
|
{
|
|
self.run_decommission_capacity_mutation(
|
|
target_pool_index,
|
|
capacity_owner,
|
|
expected_data_bytes,
|
|
DecommissionCapacityMutationMode::Durable,
|
|
|_| false,
|
|
|_| operation(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn run_decommission_capacity_admitted_mutation_with_capacity_lease<T, F, Fut>(
|
|
&self,
|
|
target_pool_index: usize,
|
|
capacity_owner: Option<DecommissionCapacityOwner>,
|
|
expected_data_bytes: Option<usize>,
|
|
operation: F,
|
|
) -> Result<T>
|
|
where
|
|
F: FnOnce(Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>) -> Fut,
|
|
Fut: std::future::Future<Output = Result<T>>,
|
|
{
|
|
self.run_decommission_capacity_mutation(
|
|
target_pool_index,
|
|
capacity_owner,
|
|
expected_data_bytes,
|
|
DecommissionCapacityMutationMode::Durable,
|
|
|_| false,
|
|
operation,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn reconcile_decommission_capacity_before_exact_delete(
|
|
&self,
|
|
bucket: &str,
|
|
object: &str,
|
|
opts: &ObjectOptions,
|
|
exact: &ObjectInfo,
|
|
) -> Result<()> {
|
|
if exact.bucket != bucket || exact.name != decode_dir_object(object) {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"exact delete object identity changed before capacity reconciliation",
|
|
));
|
|
}
|
|
|
|
let (reconciliations, model_version) = {
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (_read_guard, snapshot) = self
|
|
.acquire_pool_meta_read_guard(&mut save_guard, "exact delete capacity reconciliation failed")
|
|
.await?;
|
|
let reconciliations = plan_exact_delete_capacity_reconciliations(&snapshot, object, exact)?;
|
|
let model_version = active_decommission_capacity_model(&snapshot)?;
|
|
(reconciliations, model_version)
|
|
};
|
|
if reconciliations.is_empty() {
|
|
return Ok(());
|
|
}
|
|
ensure_exact_delete_capacity_namespace_fences(opts, bucket, object)?;
|
|
|
|
let mut target_guards = Vec::new();
|
|
let mut legacy_write_fence = None;
|
|
match model_version {
|
|
Some(DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION) => {
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (write_guard, snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "exact delete capacity reconciliation failed")
|
|
.await?;
|
|
if active_decommission_capacity_model(&snapshot)? != model_version
|
|
|| plan_exact_delete_capacity_reconciliations(&snapshot, object, exact)? != reconciliations
|
|
{
|
|
return Err(decommission_capacity_blocked_error(
|
|
"pending capacity changed before exact target evidence could be verified",
|
|
));
|
|
}
|
|
legacy_write_fence = Some((save_guard, write_guard, snapshot));
|
|
}
|
|
Some(DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION) => {
|
|
let mut target_pool_indices = reconciliations
|
|
.iter()
|
|
.map(|reconciliation| reconciliation.target_pool_index)
|
|
.collect::<Vec<_>>();
|
|
target_pool_indices.sort_unstable();
|
|
target_pool_indices.dedup();
|
|
target_guards.reserve(target_pool_indices.len());
|
|
for target_pool_index in target_pool_indices {
|
|
let guard = self.acquire_decommission_capacity_target_guard(target_pool_index).await?;
|
|
ensure_decommission_capacity_target_fence(&guard, target_pool_index, "exact delete evidence")?;
|
|
target_guards.push((target_pool_index, guard));
|
|
}
|
|
}
|
|
Some(version) => {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"exact delete capacity reconciliation found unsupported lock model {version}"
|
|
)));
|
|
}
|
|
None => {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"exact delete capacity reconciliation has no active reservation lock model",
|
|
));
|
|
}
|
|
}
|
|
|
|
let target_lookup_options = ObjectOptions {
|
|
versioned: opts.versioned,
|
|
version_suspended: opts.version_suspended,
|
|
version_id: opts.version_id.clone(),
|
|
metadata_chg: opts.version_id.is_some(),
|
|
no_lock: true,
|
|
..Default::default()
|
|
};
|
|
for reconciliation in &reconciliations {
|
|
let target_pool = self.pools.get(reconciliation.target_pool_index).ok_or_else(|| {
|
|
decommission_capacity_blocked_error(format!(
|
|
"source pool {} exact-delete capacity target pool {} is out of range",
|
|
reconciliation.source_pool_index, reconciliation.target_pool_index
|
|
))
|
|
})?;
|
|
let target = target_pool
|
|
.get_object_info(bucket, object, &target_lookup_options)
|
|
.await
|
|
.map_err(|err| {
|
|
decommission_capacity_blocked_error(format!(
|
|
"source pool {} target pool {} exact object evidence could not be read: {err}",
|
|
reconciliation.source_pool_index, reconciliation.target_pool_index
|
|
))
|
|
})?;
|
|
if !Self::is_equivalent_decommission_capacity_target(exact, &target) {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {} target pool {} does not contain an equivalent exact object for its pending capacity intent",
|
|
reconciliation.source_pool_index, reconciliation.target_pool_index
|
|
)));
|
|
}
|
|
}
|
|
ensure_exact_delete_capacity_namespace_fences(opts, bucket, object)?;
|
|
|
|
let (mut save_guard, write_guard, mut snapshot) = if let Some(fence) = legacy_write_fence {
|
|
fence
|
|
} else {
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (write_guard, snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "exact delete capacity reconciliation failed")
|
|
.await?;
|
|
(save_guard, write_guard, snapshot)
|
|
};
|
|
if active_decommission_capacity_model(&snapshot)? != model_version {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"capacity lock model changed while exact target evidence was being verified",
|
|
));
|
|
}
|
|
let current_reconciliations = plan_exact_delete_capacity_reconciliations(&snapshot, object, exact)?;
|
|
if current_reconciliations != reconciliations {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"pending capacity changed while exact target evidence was being verified",
|
|
));
|
|
}
|
|
ensure_exact_delete_capacity_namespace_fences(opts, bucket, object)?;
|
|
for (target_pool_index, target_guard) in &target_guards {
|
|
ensure_decommission_capacity_target_fence(target_guard, *target_pool_index, "exact delete capacity finalize")?;
|
|
}
|
|
|
|
let now = OffsetDateTime::now_utc();
|
|
let mut source_pool_indices = Vec::with_capacity(current_reconciliations.len());
|
|
for reconciliation in current_reconciliations {
|
|
resolve_decommission_target_pending(
|
|
&mut snapshot,
|
|
reconciliation.source_pool_index,
|
|
reconciliation.target_pool_index,
|
|
reconciliation.expected_target_physical_bytes,
|
|
reconciliation.mutation_id,
|
|
)?;
|
|
record_decommission_target_consumption(
|
|
&mut snapshot,
|
|
reconciliation.source_pool_index,
|
|
reconciliation.target_pool_index,
|
|
DecommissionTargetConsumption {
|
|
committed_data_bytes: reconciliation.expected_data_bytes,
|
|
target_physical_bytes: reconciliation.expected_target_physical_bytes,
|
|
observed_physical_bytes: 0,
|
|
},
|
|
reconciliation.mutation_id,
|
|
now,
|
|
)?;
|
|
source_pool_indices.push(reconciliation.source_pool_index);
|
|
}
|
|
source_pool_indices.sort_unstable();
|
|
source_pool_indices.dedup();
|
|
ensure_exact_delete_capacity_namespace_fences(opts, bucket, object)?;
|
|
|
|
let outcome = snapshot
|
|
.save_no_lock_armed(self.pools.clone(), &mut save_guard, write_guard.lock_lost_signal(), &source_pool_indices)
|
|
.await?;
|
|
ensure_pool_meta_write_fence(&write_guard, "exact delete capacity reconciliation save failed")?;
|
|
for (target_pool_index, target_guard) in &target_guards {
|
|
ensure_decommission_capacity_target_fence(target_guard, *target_pool_index, "exact delete capacity save")?;
|
|
}
|
|
{
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
publish_pool_meta_updates(&mut pool_meta, &outcome.committed, &source_pool_indices);
|
|
}
|
|
ensure_pool_meta_write_fence(&write_guard, "exact delete capacity reconciliation save failed")?;
|
|
for (target_pool_index, target_guard) in &target_guards {
|
|
ensure_decommission_capacity_target_fence(target_guard, *target_pool_index, "exact delete capacity publication")?;
|
|
}
|
|
outcome.disarm();
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn reconcile_decommission_capacity_after_equivalent_target(
|
|
&self,
|
|
owner: DecommissionCapacityOwner,
|
|
target_pool_index: usize,
|
|
expected_data_bytes: usize,
|
|
) -> Result<()> {
|
|
let target_guard = self
|
|
.acquire_decommission_capacity_owner_target_guard(owner, target_pool_index)
|
|
.await?;
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(target_guard, target_pool_index, "equivalent target reconciliation")?;
|
|
}
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (pool_meta_guard, mut snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "decommission equivalent target reconciliation failed")
|
|
.await?;
|
|
let source_pool_index = owner.source_pool_index;
|
|
let (target_layout, target_pending_physical_bytes, target_consumed_physical_bytes) = {
|
|
let reservation = snapshot
|
|
.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| {
|
|
reservation.active()
|
|
&& reservation.source_pool_index == owner.source_pool_index
|
|
&& reservation.operation_id == owner.operation_id
|
|
&& reservation.generation == owner.generation
|
|
&& reservation.owner_nonce == owner.owner_nonce
|
|
})
|
|
.ok_or_else(|| decommission_capacity_blocked_error("equivalent target reconciliation owner is stale"))?;
|
|
let target = reservation
|
|
.targets
|
|
.iter()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("equivalent target reconciliation allocation is missing"))?;
|
|
(target.layout, target.pending_physical_bytes, target.consumed_physical_bytes)
|
|
};
|
|
let mutation_id = owner.mutation_id.ok_or_else(|| {
|
|
decommission_capacity_blocked_error("equivalent target reconciliation mutation identity is missing")
|
|
})?;
|
|
let expected_target_physical_bytes = capacity_target_physical_bytes(expected_data_bytes.max(1), target_layout)?;
|
|
if target_pending_physical_bytes == 0 {
|
|
if target_consumed_physical_bytes >= expected_target_physical_bytes {
|
|
let persisted_info = snapshot
|
|
.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.cloned()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("publish equivalent target reconciliation"))?;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
let pool_count = pool_meta.pools.len();
|
|
pool_meta.version = pool_meta.version.max(snapshot.version);
|
|
let info = pool_meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_mut())
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
info.capacity_reservation = persisted_info.capacity_reservation;
|
|
info.capacity_blocked_reason = persisted_info.capacity_blocked_reason;
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(
|
|
target_guard,
|
|
target_pool_index,
|
|
"equivalent target idempotent publication",
|
|
)?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
return Err(decommission_capacity_blocked_error(
|
|
"equivalent target has no pending capacity intent to reconcile",
|
|
));
|
|
}
|
|
if target_pending_physical_bytes < expected_target_physical_bytes {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"equivalent target pending capacity is smaller than the committed object",
|
|
));
|
|
}
|
|
|
|
resolve_decommission_target_pending(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
expected_target_physical_bytes,
|
|
mutation_id,
|
|
)?;
|
|
record_decommission_target_consumption(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
DecommissionTargetConsumption {
|
|
committed_data_bytes: expected_data_bytes,
|
|
target_physical_bytes: expected_target_physical_bytes,
|
|
observed_physical_bytes: 0,
|
|
},
|
|
mutation_id,
|
|
OffsetDateTime::now_utc(),
|
|
)?;
|
|
let outcome = snapshot
|
|
.save_no_lock_armed(
|
|
self.pools.clone(),
|
|
&mut save_guard,
|
|
pool_meta_guard.lock_lost_signal(),
|
|
&[source_pool_index],
|
|
)
|
|
.await?;
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "decommission equivalent target reconciliation save failed")?;
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(target_guard, target_pool_index, "equivalent target capacity save")?;
|
|
}
|
|
{
|
|
let persisted_info = outcome
|
|
.committed
|
|
.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.cloned()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("publish equivalent target reconciliation"))?;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
let pool_count = pool_meta.pools.len();
|
|
pool_meta.version = pool_meta.version.max(outcome.committed.version);
|
|
let info = pool_meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_mut())
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
info.capacity_reservation = persisted_info.capacity_reservation;
|
|
info.capacity_blocked_reason = persisted_info.capacity_blocked_reason;
|
|
}
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "decommission equivalent target reconciliation save failed")?;
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(target_guard, target_pool_index, "equivalent target capacity publication")?;
|
|
}
|
|
outcome.disarm();
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn run_decommission_capacity_temporary_mutation<T, F, Fut>(
|
|
&self,
|
|
target_pool_index: usize,
|
|
capacity_owner: Option<DecommissionCapacityOwner>,
|
|
expected_data_bytes: Option<usize>,
|
|
operation: F,
|
|
) -> Result<T>
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: std::future::Future<Output = Result<T>>,
|
|
{
|
|
self.run_decommission_capacity_mutation(
|
|
target_pool_index,
|
|
capacity_owner,
|
|
expected_data_bytes,
|
|
DecommissionCapacityMutationMode::Temporary,
|
|
|_| false,
|
|
|_| operation(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn run_decommission_capacity_temporary_mutation_with_capacity_lease<T, F, Fut>(
|
|
&self,
|
|
target_pool_index: usize,
|
|
capacity_owner: Option<DecommissionCapacityOwner>,
|
|
expected_data_bytes: Option<usize>,
|
|
operation: F,
|
|
) -> Result<T>
|
|
where
|
|
F: FnOnce(Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>) -> Fut,
|
|
Fut: std::future::Future<Output = Result<T>>,
|
|
{
|
|
self.run_decommission_capacity_mutation(
|
|
target_pool_index,
|
|
capacity_owner,
|
|
expected_data_bytes,
|
|
DecommissionCapacityMutationMode::Temporary,
|
|
|_| false,
|
|
operation,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Run an identity-preserving replacement that the caller has already
|
|
/// proven cannot grow the target object. A failed or ambiguous write keeps
|
|
/// the ordinary temporary-mutation recovery marker, while a successful
|
|
/// write resolves the capacity intent without retaining MPU cleanup state.
|
|
pub(crate) async fn run_decommission_capacity_non_growing_replacement_with_capacity_lease<T, F, Fut>(
|
|
&self,
|
|
target_pool_index: usize,
|
|
capacity_owner: Option<DecommissionCapacityOwner>,
|
|
expected_data_bytes: Option<usize>,
|
|
operation: F,
|
|
) -> Result<T>
|
|
where
|
|
F: FnOnce(Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>) -> Fut,
|
|
Fut: std::future::Future<Output = Result<T>>,
|
|
{
|
|
self.run_decommission_capacity_mutation(
|
|
target_pool_index,
|
|
capacity_owner,
|
|
expected_data_bytes,
|
|
DecommissionCapacityMutationMode::NonGrowingReplacement,
|
|
|_| false,
|
|
operation,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Finish the capacity transaction for an identity-preserving temporary
|
|
/// replacement whose target bytes are already durably present. This is
|
|
/// the crash-recovery half of a non-growing replacement: it never writes
|
|
/// the target again and only settles state owned by the exact deterministic
|
|
/// mutation id.
|
|
pub(crate) async fn reconcile_decommission_capacity_after_equivalent_temporary_target(
|
|
&self,
|
|
owner: DecommissionCapacityOwner,
|
|
target_pool_index: usize,
|
|
expected_data_bytes: usize,
|
|
) -> Result<()> {
|
|
let target_guard = self
|
|
.acquire_decommission_capacity_owner_target_guard(owner, target_pool_index)
|
|
.await?;
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(
|
|
target_guard,
|
|
target_pool_index,
|
|
"equivalent temporary target reconciliation",
|
|
)?;
|
|
}
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (pool_meta_guard, mut snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "decommission equivalent temporary target reconciliation failed")
|
|
.await?;
|
|
let source_pool_index = owner.source_pool_index;
|
|
let mutation_id = owner
|
|
.mutation_id
|
|
.ok_or_else(|| decommission_capacity_blocked_error("equivalent temporary target mutation identity is missing"))?;
|
|
let (target_layout, pending_physical_bytes, pending_mutation_id, has_temporary_mutation) = {
|
|
let reservation = snapshot
|
|
.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| reservation.admits_cleanup_owner(owner))
|
|
.ok_or_else(|| decommission_capacity_blocked_error("equivalent temporary target owner is stale"))?;
|
|
let target = reservation
|
|
.targets
|
|
.iter()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("equivalent temporary target allocation is missing"))?;
|
|
(
|
|
target.layout,
|
|
target.pending_physical_bytes,
|
|
target.pending_mutation_id,
|
|
target
|
|
.temporary_mutations
|
|
.iter()
|
|
.any(|mutation| mutation.mutation_id == mutation_id),
|
|
)
|
|
};
|
|
if pending_physical_bytes == 0 && !has_temporary_mutation {
|
|
// A prior successful attempt already settled both durable halves.
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "equivalent temporary target reconciliation fence failed")?;
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(
|
|
target_guard,
|
|
target_pool_index,
|
|
"equivalent temporary target idempotent reconciliation",
|
|
)?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
if pending_physical_bytes > 0 && pending_mutation_id != Some(mutation_id) {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"equivalent temporary target pending intent belongs to another mutation",
|
|
));
|
|
}
|
|
let expected_target_physical_bytes = capacity_target_physical_bytes(expected_data_bytes.max(1), target_layout)?;
|
|
if pending_physical_bytes > 0 && pending_physical_bytes != expected_target_physical_bytes {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"equivalent temporary target pending capacity does not match the committed checkpoint",
|
|
));
|
|
}
|
|
if pending_physical_bytes > 0 {
|
|
resolve_decommission_target_pending(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
expected_target_physical_bytes,
|
|
mutation_id,
|
|
)?;
|
|
}
|
|
// Exact byte equivalence proves that this identity-preserving,
|
|
// byte-non-growing replacement committed. Settle both crash windows:
|
|
// the pending intent before finalize and the temporary marker written
|
|
// when the operation returned an error after committing its target.
|
|
settle_decommission_target_non_growing_replacement(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
mutation_id,
|
|
OffsetDateTime::now_utc(),
|
|
)?;
|
|
let outcome = snapshot
|
|
.save_no_lock_armed(
|
|
self.pools.clone(),
|
|
&mut save_guard,
|
|
pool_meta_guard.lock_lost_signal(),
|
|
&[source_pool_index],
|
|
)
|
|
.await?;
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "equivalent temporary target reconciliation save failed")?;
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(
|
|
target_guard,
|
|
target_pool_index,
|
|
"equivalent temporary target capacity save",
|
|
)?;
|
|
}
|
|
let persisted_info = outcome
|
|
.committed
|
|
.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.cloned()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("publish equivalent temporary target reconciliation"))?;
|
|
{
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
let pool_count = pool_meta.pools.len();
|
|
pool_meta.version = pool_meta.version.max(outcome.committed.version);
|
|
let info = pool_meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_mut())
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
info.capacity_reservation = persisted_info.capacity_reservation;
|
|
info.capacity_blocked_reason = persisted_info.capacity_blocked_reason;
|
|
}
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "equivalent temporary target reconciliation save failed")?;
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(
|
|
target_guard,
|
|
target_pool_index,
|
|
"equivalent temporary target capacity publication",
|
|
)?;
|
|
}
|
|
outcome.disarm();
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn has_decommission_capacity_temporary_mutation_state(
|
|
&self,
|
|
target_pool_index: usize,
|
|
owner: DecommissionCapacityOwner,
|
|
) -> bool {
|
|
let Some(mutation_id) = owner.mutation_id else {
|
|
return false;
|
|
};
|
|
self.pool_meta
|
|
.read()
|
|
.await
|
|
.pools
|
|
.get(owner.source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| reservation.admits_cleanup_owner(owner))
|
|
.and_then(|reservation| {
|
|
reservation
|
|
.targets
|
|
.iter()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.map(|target| (reservation.model_version, target))
|
|
})
|
|
.is_some_and(|(model_version, target)| {
|
|
if model_version == DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION {
|
|
// V1 cannot persist an exact zero-byte staging marker, so
|
|
// retain its compatibility scan for admitted cleanup targets.
|
|
true
|
|
} else {
|
|
(target.pending_physical_bytes > 0 && target.pending_mutation_id == Some(mutation_id))
|
|
|| target
|
|
.temporary_mutations
|
|
.iter()
|
|
.any(|mutation| mutation.mutation_id == mutation_id)
|
|
}
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn decommission_capacity_cleanup_target_indices(
|
|
&self,
|
|
owner: DecommissionCapacityOwner,
|
|
) -> Result<Vec<usize>> {
|
|
self.pool_meta
|
|
.read()
|
|
.await
|
|
.pools
|
|
.get(owner.source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| reservation.admits_cleanup_owner(owner))
|
|
.map(|reservation| reservation.targets.iter().map(|target| target.pool_index).collect())
|
|
.ok_or_else(|| decommission_capacity_blocked_error("decommission multipart cleanup reservation is stale"))
|
|
}
|
|
|
|
pub(crate) async fn run_decommission_capacity_temporary_release_with_capacity_lease<T, F, Fut>(
|
|
&self,
|
|
target_pool_index: usize,
|
|
capacity_owner: Option<DecommissionCapacityOwner>,
|
|
operation: F,
|
|
) -> Result<T>
|
|
where
|
|
F: FnOnce(Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>) -> Fut,
|
|
Fut: std::future::Future<Output = Result<(T, bool)>>,
|
|
{
|
|
self.run_decommission_capacity_mutation(
|
|
target_pool_index,
|
|
capacity_owner,
|
|
None,
|
|
DecommissionCapacityMutationMode::TemporaryRelease,
|
|
|result: &(T, bool)| result.1,
|
|
operation,
|
|
)
|
|
.await
|
|
.map(|(result, _)| result)
|
|
}
|
|
|
|
async fn run_decommission_capacity_mutation<T, F, Fut, P>(
|
|
&self,
|
|
target_pool_index: usize,
|
|
capacity_owner: Option<DecommissionCapacityOwner>,
|
|
expected_data_bytes: Option<usize>,
|
|
mode: DecommissionCapacityMutationMode,
|
|
clear_pending_on_temporary_release: P,
|
|
operation: F,
|
|
) -> Result<T>
|
|
where
|
|
F: FnOnce(Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>) -> Fut,
|
|
Fut: std::future::Future<Output = Result<T>>,
|
|
P: Fn(&T) -> bool,
|
|
{
|
|
let temporary = matches!(
|
|
mode,
|
|
DecommissionCapacityMutationMode::Temporary | DecommissionCapacityMutationMode::NonGrowingReplacement
|
|
);
|
|
let non_growing_replacement = matches!(mode, DecommissionCapacityMutationMode::NonGrowingReplacement);
|
|
let temporary_release = matches!(mode, DecommissionCapacityMutationMode::TemporaryRelease);
|
|
let mut operation = Some(operation);
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (read_guard, snapshot) = self
|
|
.acquire_pool_meta_read_guard(&mut save_guard, "target capacity admission failed")
|
|
.await?;
|
|
let admission_now = OffsetDateTime::now_utc();
|
|
let admitted_owner = capacity_owner.and_then(|owner| {
|
|
snapshot
|
|
.pools
|
|
.get(owner.source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| {
|
|
if temporary_release {
|
|
reservation.admits_cleanup_owner(owner)
|
|
} else {
|
|
reservation.admits_owner(owner, admission_now)
|
|
}
|
|
})
|
|
.filter(|reservation| {
|
|
reservation
|
|
.targets
|
|
.iter()
|
|
.any(|target| target.pool_index == target_pool_index)
|
|
})
|
|
.map(|reservation| (owner, reservation.model_version))
|
|
});
|
|
if capacity_owner.is_some() && admitted_owner.is_none() {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"decommission target mutation reservation identity is stale",
|
|
));
|
|
}
|
|
let Some((owner, model_version)) = admitted_owner else {
|
|
ensure_external_decommission_target_admission(&snapshot, target_pool_index, "mutation")?;
|
|
drop(save_guard);
|
|
let capacity_lease = read_guard.lock_lost_signal();
|
|
return operation.take().expect("capacity-admitted operation should run once")(capacity_lease).await;
|
|
};
|
|
|
|
// Per-target reservations always acquire target -> pool metadata.
|
|
// Legacy reservations keep the pool metadata write guard through I/O.
|
|
drop(read_guard);
|
|
drop(save_guard);
|
|
if model_version == DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION {
|
|
discard_decommission_capacity_target_permit_except(self.id, owner, Some(target_pool_index));
|
|
}
|
|
let target_guard = match model_version {
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION => None,
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION => {
|
|
Some(match take_decommission_capacity_target_permit(self.id, target_pool_index, owner) {
|
|
Some(guard) => guard,
|
|
None => self.acquire_decommission_capacity_target_guard(target_pool_index).await?,
|
|
})
|
|
}
|
|
version => {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"decommission target mutation found unsupported capacity lock model {version}"
|
|
)));
|
|
}
|
|
};
|
|
|
|
#[cfg(test)]
|
|
pause_decommission_capacity_before_owner_write(self.id).await;
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (write_guard, mut snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "decommission target capacity admission failed")
|
|
.await?;
|
|
let mutation_id = owner
|
|
.mutation_id
|
|
.ok_or_else(|| decommission_capacity_blocked_error("decommission mutation identity is missing"))?;
|
|
let source_pool_index = owner.source_pool_index;
|
|
let owner_current = snapshot
|
|
.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| {
|
|
(if temporary_release {
|
|
reservation.admits_cleanup_owner(owner)
|
|
} else {
|
|
reservation.admits_owner(owner, OffsetDateTime::now_utc())
|
|
}) && reservation.model_version == model_version
|
|
&& reservation
|
|
.targets
|
|
.iter()
|
|
.any(|target| target.pool_index == target_pool_index)
|
|
})
|
|
.is_some();
|
|
if !owner_current {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"decommission target mutation reservation identity changed before commit",
|
|
));
|
|
}
|
|
let capacity_infos = self.get_decommission_all_pool_capacity_infos().await?;
|
|
if !temporary_release {
|
|
ensure_decommission_capacity_reservations_available(&snapshot, &capacity_infos, "mutation")?;
|
|
}
|
|
let target_layout = snapshot
|
|
.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.and_then(|reservation| {
|
|
reservation
|
|
.targets
|
|
.iter()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
})
|
|
.map(|target| target.layout)
|
|
.ok_or_else(|| Error::SlowDown)?;
|
|
let expected_target_physical_bytes = if temporary_release {
|
|
0
|
|
} else {
|
|
capacity_target_physical_bytes(expected_data_bytes.unwrap_or(1).max(1), target_layout)?
|
|
};
|
|
if temporary {
|
|
let (remaining, inflight) = snapshot
|
|
.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.and_then(|reservation| {
|
|
reservation
|
|
.targets
|
|
.iter()
|
|
.find(|target| target.pool_index == target_pool_index)
|
|
.map(|target| {
|
|
(
|
|
target.remaining_reserved_physical_bytes(reservation.temporary_copies)
|
|
/ 1usize.saturating_add(reservation.temporary_copies),
|
|
target.inflight_physical_bytes,
|
|
)
|
|
})
|
|
})
|
|
.unwrap_or_default();
|
|
let available = remaining.saturating_sub(inflight);
|
|
if expected_target_physical_bytes > available {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"source pool {source_pool_index} target pool {target_pool_index} temporary operation requires {expected_target_physical_bytes} physical bytes, but only {available} temporary-copy bytes remain"
|
|
)));
|
|
}
|
|
} else {
|
|
ensure_decommission_target_owner_admission(
|
|
&snapshot,
|
|
owner,
|
|
target_pool_index,
|
|
expected_target_physical_bytes,
|
|
OffsetDateTime::now_utc(),
|
|
)?;
|
|
}
|
|
let pending_added = if temporary_release {
|
|
0
|
|
} else {
|
|
reserve_decommission_target_pending(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
expected_target_physical_bytes,
|
|
mutation_id,
|
|
OffsetDateTime::now_utc(),
|
|
)?
|
|
};
|
|
if pending_added > 0 {
|
|
let outcome = snapshot
|
|
.save_no_lock_armed(self.pools.clone(), &mut save_guard, write_guard.lock_lost_signal(), &[source_pool_index])
|
|
.await?;
|
|
ensure_pool_meta_write_fence(&write_guard, "decommission target capacity intent save failed")?;
|
|
snapshot = outcome.committed.clone();
|
|
{
|
|
let persisted_info = snapshot
|
|
.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.cloned()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("publish target capacity intent"))?;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
let pool_count = pool_meta.pools.len();
|
|
pool_meta.version = pool_meta.version.max(snapshot.version);
|
|
let info = pool_meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_mut())
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
info.capacity_reservation = persisted_info.capacity_reservation;
|
|
info.capacity_blocked_reason = persisted_info.capacity_blocked_reason;
|
|
}
|
|
ensure_pool_meta_write_fence(&write_guard, "decommission target capacity intent save failed")?;
|
|
outcome.disarm();
|
|
}
|
|
let mut write_guard = Some(write_guard);
|
|
let mut save_guard = Some(save_guard);
|
|
let capacity_lease = if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(target_guard, target_pool_index, "capacity intent prepare")?;
|
|
let capacity_lease = target_guard.lock_lost_signal();
|
|
drop(write_guard.take());
|
|
drop(save_guard.take());
|
|
capacity_lease
|
|
} else {
|
|
write_guard
|
|
.as_ref()
|
|
.ok_or_else(|| Error::other("legacy decommission capacity fence disappeared before target mutation"))?
|
|
.lock_lost_signal()
|
|
};
|
|
|
|
let capacity_infos = if pending_added > 0 {
|
|
self.get_decommission_all_pool_capacity_infos().await?
|
|
} else {
|
|
capacity_infos
|
|
};
|
|
let before_free = capacity_infos
|
|
.iter()
|
|
.find(|capacity| capacity.pool_index == target_pool_index)
|
|
.map(|capacity| capacity.physical_free)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("target capacity snapshot is missing before mutation"))?;
|
|
|
|
let result = operation.take().expect("capacity-admitted operation should run once")(capacity_lease).await;
|
|
let clear_pending_on_temporary_release = result.as_ref().ok().is_some_and(&clear_pending_on_temporary_release);
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(target_guard, target_pool_index, "target mutation")?;
|
|
let mut finalize_save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (finalize_write_guard, finalize_snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut finalize_save_guard, "decommission target capacity finalize failed")
|
|
.await?;
|
|
ensure_decommission_capacity_target_fence(target_guard, target_pool_index, "capacity finalize")?;
|
|
save_guard = Some(finalize_save_guard);
|
|
write_guard = Some(finalize_write_guard);
|
|
snapshot = finalize_snapshot;
|
|
}
|
|
let write_guard = write_guard
|
|
.as_ref()
|
|
.ok_or_else(|| Error::other("decommission capacity write fence disappeared before finalize"))?;
|
|
let save_guard = save_guard
|
|
.as_mut()
|
|
.ok_or_else(|| Error::other("decommission capacity save fence disappeared before finalize"))?;
|
|
ensure_pool_meta_write_fence(write_guard, "decommission target capacity finalize failed")?;
|
|
ensure_decommission_capacity_mutation_intent_current(
|
|
&snapshot,
|
|
owner,
|
|
target_pool_index,
|
|
expected_target_physical_bytes,
|
|
mutation_id,
|
|
temporary_release,
|
|
model_version,
|
|
)?;
|
|
let capacity_infos = self.get_decommission_all_pool_capacity_infos().await?;
|
|
let after_free = capacity_infos
|
|
.iter()
|
|
.find(|capacity| capacity.pool_index == target_pool_index)
|
|
.map(|capacity| capacity.physical_free)
|
|
.ok_or_else(|| decommission_capacity_blocked_error("target capacity snapshot is missing after mutation"))?;
|
|
let observed_physical_bytes = before_free.saturating_sub(after_free);
|
|
let released_physical_bytes = after_free.saturating_sub(before_free);
|
|
let now = OffsetDateTime::now_utc();
|
|
let progress_changed = if temporary_release {
|
|
if result.is_ok() {
|
|
release_decommission_target_inflight(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
released_physical_bytes,
|
|
mutation_id,
|
|
DecommissionCapacityReleaseProof::confirmed_absence(clear_pending_on_temporary_release),
|
|
now,
|
|
)?
|
|
} else {
|
|
false
|
|
}
|
|
} else if result.is_ok() {
|
|
resolve_decommission_target_pending(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
expected_target_physical_bytes,
|
|
mutation_id,
|
|
)?;
|
|
if temporary {
|
|
if non_growing_replacement {
|
|
settle_decommission_target_non_growing_replacement(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
mutation_id,
|
|
now,
|
|
)?;
|
|
} else {
|
|
record_decommission_target_inflight(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
observed_physical_bytes,
|
|
mutation_id,
|
|
now,
|
|
)?;
|
|
}
|
|
} else {
|
|
let consumed_physical_bytes = expected_data_bytes
|
|
.map(|_| expected_target_physical_bytes)
|
|
.unwrap_or(observed_physical_bytes.max(expected_target_physical_bytes));
|
|
let committed_data_bytes =
|
|
expected_data_bytes.unwrap_or(capacity_source_data_equivalent(observed_physical_bytes, target_layout)?);
|
|
record_decommission_target_consumption(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
DecommissionTargetConsumption {
|
|
committed_data_bytes,
|
|
target_physical_bytes: consumed_physical_bytes,
|
|
observed_physical_bytes,
|
|
},
|
|
mutation_id,
|
|
now,
|
|
)?;
|
|
}
|
|
true
|
|
} else if temporary {
|
|
record_decommission_target_inflight(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
observed_physical_bytes,
|
|
mutation_id,
|
|
now,
|
|
)?
|
|
} else {
|
|
record_decommission_target_observation(
|
|
&mut snapshot,
|
|
source_pool_index,
|
|
target_pool_index,
|
|
observed_physical_bytes,
|
|
now,
|
|
)?;
|
|
observed_physical_bytes > 0
|
|
};
|
|
let capacity_result = if temporary_release {
|
|
Ok(())
|
|
} else {
|
|
ensure_decommission_capacity_reservations_available(&snapshot, &capacity_infos, "mutation")
|
|
};
|
|
if let Err(err) = &capacity_result {
|
|
snapshot.mark_decommission_capacity_blocked(source_pool_index, err.to_string(), now)?;
|
|
}
|
|
if temporary_release && !progress_changed {
|
|
ensure_pool_meta_write_fence(write_guard, "decommission target capacity cleanup fence failed")?;
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(target_guard, target_pool_index, "capacity cleanup")?;
|
|
}
|
|
capacity_result?;
|
|
return result;
|
|
}
|
|
let outcome = snapshot
|
|
.save_no_lock_armed(self.pools.clone(), save_guard, write_guard.lock_lost_signal(), &[source_pool_index])
|
|
.await?;
|
|
ensure_pool_meta_write_fence(write_guard, "decommission target capacity progress save failed")?;
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(target_guard, target_pool_index, "capacity progress save")?;
|
|
}
|
|
{
|
|
let persisted_info = outcome
|
|
.committed
|
|
.pools
|
|
.get(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.cloned()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("publish target capacity progress"))?;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
let pool_count = pool_meta.pools.len();
|
|
pool_meta.version = pool_meta.version.max(outcome.committed.version);
|
|
let info = pool_meta
|
|
.pools
|
|
.get_mut(source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_mut())
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_count, source_pool_index))?;
|
|
info.capacity_reservation = persisted_info.capacity_reservation;
|
|
info.capacity_blocked_reason = persisted_info.capacity_blocked_reason;
|
|
}
|
|
ensure_pool_meta_write_fence(write_guard, "decommission target capacity progress save failed")?;
|
|
if let Some(target_guard) = target_guard.as_ref() {
|
|
ensure_decommission_capacity_target_fence(target_guard, target_pool_index, "capacity progress publication")?;
|
|
}
|
|
outcome.disarm();
|
|
capacity_result?;
|
|
result
|
|
}
|
|
|
|
pub(crate) async fn ensure_pool_meta_side_effects_safe(&self, operation: &str) -> Result<()> {
|
|
self.pool_meta_save_gate.lock().await.ensure_write_safe(operation)
|
|
}
|
|
|
|
async fn load_runtime_pool_meta_observing(&self, write_state: &mut PoolMetaWriteState, operation: &str) -> Result<PoolMeta> {
|
|
write_state.ensure_write_safe(operation)?;
|
|
load_pool_meta_identity_observing(self.pools.clone(), write_state).await?;
|
|
let mut pool_meta = PoolMeta::default();
|
|
let replica_state = pool_meta
|
|
.load_no_lock_from_replicas_observing(self.pools.clone(), write_state)
|
|
.await?;
|
|
write_state.observe_replicas(replica_state);
|
|
write_state.ensure_missing_metadata_can_initialize()?;
|
|
write_state.ensure_write_safe(operation)?;
|
|
Ok(pool_meta)
|
|
}
|
|
|
|
pub(crate) async fn load_runtime_pool_meta_under_activation_fence(
|
|
&self,
|
|
write_state: &mut PoolMetaWriteState,
|
|
activation_fence: &PoolRebalanceActivationFence,
|
|
operation: &str,
|
|
) -> Result<PoolMeta> {
|
|
activation_fence.ensure_held()?;
|
|
let pool_meta = self.load_runtime_pool_meta_observing(write_state, operation).await?;
|
|
activation_fence.ensure_held()?;
|
|
Ok(pool_meta)
|
|
}
|
|
|
|
pub(crate) async fn load_runtime_pool_meta(&self, operation: &str) -> Result<PoolMeta> {
|
|
let mut write_state = self.pool_meta_save_gate.lock().await;
|
|
write_state.ensure_write_safe(operation)?;
|
|
let pool = self
|
|
.pools
|
|
.first()
|
|
.cloned()
|
|
.ok_or_else(|| Error::other(format!("{operation}: no storage pools available")))?;
|
|
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
|
|
let _pool_meta_guard = pool_meta_lock.get_read_lock(get_lock_acquire_timeout()).await?;
|
|
self.load_runtime_pool_meta_observing(&mut write_state, operation).await
|
|
}
|
|
|
|
async fn run_guarded_decommission_side_effect<T, E, F, Fut>(
|
|
&self,
|
|
rx: &CancellationToken,
|
|
operation_gate: &Arc<tokio::sync::RwLock<()>>,
|
|
operation: F,
|
|
) -> std::result::Result<T, E>
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: std::future::Future<Output = std::result::Result<T, E>>,
|
|
E: From<Error>,
|
|
{
|
|
let _operation_guard = tokio::select! {
|
|
biased;
|
|
_ = rx.cancelled() => return Err(Error::OperationCanceled.into()),
|
|
guard = operation_gate.read() => guard,
|
|
};
|
|
|
|
if rx.is_cancelled() {
|
|
return Err(Error::OperationCanceled.into());
|
|
}
|
|
self.ensure_pool_meta_side_effects_safe("decommission side effect blocked because pool metadata requires recovery")
|
|
.await
|
|
.map_err(E::from)?;
|
|
if rx.is_cancelled() {
|
|
return Err(Error::OperationCanceled.into());
|
|
}
|
|
|
|
let result = operation().await;
|
|
if rx.is_cancelled() {
|
|
return Err(Error::OperationCanceled.into());
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn save_current_pool_meta(&self, indices: &[usize]) -> Result<()> {
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (pool_meta_guard, mut snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "pool metadata save failed")
|
|
.await?;
|
|
{
|
|
let pool_meta = self.pool_meta.read().await;
|
|
merge_pool_meta_updates_for_save(&mut snapshot, &pool_meta, indices, "pool metadata save failed")?;
|
|
}
|
|
let outcome = snapshot
|
|
.save_no_lock_armed(self.pools.clone(), &mut save_guard, pool_meta_guard.lock_lost_signal(), indices)
|
|
.await?;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "pool metadata save failed")?;
|
|
publish_pool_meta_updates(&mut pool_meta, &outcome.committed, indices);
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "pool metadata save failed")?;
|
|
drop(pool_meta);
|
|
outcome.disarm();
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn save_current_pool_meta_for_test(&self, indices: &[usize]) -> Result<()> {
|
|
self.save_current_pool_meta(indices).await
|
|
}
|
|
|
|
async fn persist_decommission_unresolved_entry(
|
|
&self,
|
|
idx: usize,
|
|
generation: OffsetDateTime,
|
|
entry: DecommissionUnresolvedEntry,
|
|
) -> Result<()> {
|
|
{
|
|
let rebalance_meta = self.rebalance_meta.read().await.clone();
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
ensure_decommission_ledger_persistence_supported(&pool_meta)?;
|
|
record_decommission_unresolved_entry(
|
|
&mut pool_meta,
|
|
idx,
|
|
generation,
|
|
entry,
|
|
OffsetDateTime::now_utc(),
|
|
rebalance_meta.as_ref(),
|
|
)?;
|
|
}
|
|
self.save_current_pool_meta(&[idx])
|
|
.await
|
|
.map_err(|err| Error::other(format!("decommission unresolved entry ledger save failed: {err}")))
|
|
}
|
|
|
|
async fn save_decommission_progress_checkpoint(&self, idx: usize, generation: OffsetDateTime) -> Result<bool> {
|
|
self.save_decommission_progress_checkpoint_at(idx, generation, OffsetDateTime::now_utc())
|
|
.await
|
|
}
|
|
|
|
async fn save_decommission_progress_checkpoint_at(
|
|
&self,
|
|
idx: usize,
|
|
generation: OffsetDateTime,
|
|
now: OffsetDateTime,
|
|
) -> Result<bool> {
|
|
// Lock order: save gate, rebalance metadata, then the short pool
|
|
// metadata read/write sections. Peer reloads are intentionally
|
|
// performed by the caller after both locks are released.
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (pool_meta_guard, mut snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "decommission progress save failed")
|
|
.await?;
|
|
let (snapshot, checkpoint) = {
|
|
let rebalance_meta = self.rebalance_meta.read().await.clone();
|
|
let pool_meta = self.pool_meta.read().await;
|
|
ensure_decommission_generation(&pool_meta, idx, generation)?;
|
|
let Some(checkpoint) = pool_meta.decommission_progress_checkpoint(
|
|
idx,
|
|
DECOMMISSION_PROGRESS_SAVE_INTERVAL,
|
|
now,
|
|
rebalance_meta.as_ref(),
|
|
)?
|
|
else {
|
|
return Ok(false);
|
|
};
|
|
|
|
let mut current = pool_meta.clone();
|
|
let current_count = current.pools.len();
|
|
let Some(pool) = current.pools.get_mut(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(current_count, idx));
|
|
};
|
|
pool.last_update = checkpoint.checkpoint_at;
|
|
if let (Some(operation_id), Some(owner_nonce), Some(expires_at), Some(reservation)) = (
|
|
checkpoint.capacity_operation_id,
|
|
checkpoint.capacity_owner_nonce,
|
|
checkpoint.capacity_lease_expires_at,
|
|
pool.decommission.as_mut().and_then(|info| info.capacity_reservation.as_mut()),
|
|
) && reservation.operation_id == operation_id
|
|
&& reservation.owner_nonce == owner_nonce
|
|
&& reservation.active()
|
|
{
|
|
reservation.renewed_at = checkpoint.checkpoint_at;
|
|
reservation.expires_at = expires_at;
|
|
}
|
|
merge_pool_meta_updates_for_save(&mut snapshot, ¤t, &[idx], "decommission progress save failed")?;
|
|
(snapshot, checkpoint)
|
|
};
|
|
|
|
let outcome = match snapshot
|
|
.save_no_lock_armed(self.pools.clone(), &mut save_guard, pool_meta_guard.lock_lost_signal(), &[idx])
|
|
.await
|
|
{
|
|
Ok(outcome) => outcome,
|
|
Err(err) => {
|
|
let retry_after = OffsetDateTime::now_utc() + DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
pool_meta.defer_decommission_progress_checkpoint(idx, checkpoint, retry_after);
|
|
return Err(err);
|
|
}
|
|
};
|
|
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "decommission progress save failed")?;
|
|
pool_meta.version = pool_meta.version.max(outcome.committed.version);
|
|
let committed = pool_meta.commit_decommission_progress_checkpoint(idx, checkpoint);
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "decommission progress save failed")?;
|
|
drop(pool_meta);
|
|
outcome.disarm();
|
|
Ok(committed)
|
|
}
|
|
|
|
async fn mark_decommission_bucket_done_and_save(&self, idx: usize, bucket: &DecomBucketInfo) -> Result<bool> {
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (pool_meta_guard, mut snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "decommission bucket completion save failed")
|
|
.await?;
|
|
let changed = {
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
let changed = mark_decommission_bucket_done(&mut pool_meta, idx, bucket)?;
|
|
if changed {
|
|
merge_pool_meta_updates_for_save(
|
|
&mut snapshot,
|
|
&pool_meta,
|
|
&[idx],
|
|
"decommission bucket completion save failed",
|
|
)?;
|
|
}
|
|
changed
|
|
};
|
|
if !changed {
|
|
return Ok(false);
|
|
}
|
|
|
|
let outcome = snapshot
|
|
.save_no_lock_armed(self.pools.clone(), &mut save_guard, pool_meta_guard.lock_lost_signal(), &[idx])
|
|
.await
|
|
.map_err(|err| {
|
|
Error::other(format!("decommission metadata save failed for pool {idx} bucket {}: {err}", bucket.name))
|
|
})?;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "decommission bucket completion save failed")?;
|
|
pool_meta.version = pool_meta.version.max(outcome.committed.version);
|
|
pool_meta.mark_decommission_progress_saved();
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "decommission bucket completion save failed")?;
|
|
drop(pool_meta);
|
|
outcome.disarm();
|
|
Ok(true)
|
|
}
|
|
|
|
pub(crate) async fn save_current_pool_meta_for_decommission_start(
|
|
&self,
|
|
indices: &[usize],
|
|
decom_buckets: Vec<DecomBucketInfo>,
|
|
) -> Result<PoolMeta> {
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
save_guard.ensure_write_safe("decommission start failed")?;
|
|
let rebalance_pool = self
|
|
.pools
|
|
.first()
|
|
.cloned()
|
|
.ok_or_else(|| Error::other("decommission start rebalance metadata load failed: no storage pools available"))?;
|
|
#[cfg(test)]
|
|
observe_pool_activation_start_attempt(PoolActivationStartKind::Decommission);
|
|
let fleet_proof = acquire_pool_activation_fleet_proof(&self.ctx).await?;
|
|
let mut activation_fence = acquire_pool_rebalance_activation_locks(rebalance_pool.clone(), fleet_proof).await?;
|
|
|
|
let mut rebalance_meta = RebalanceMeta::new();
|
|
match rebalance_meta
|
|
.load_with_opts(
|
|
rebalance_pool.clone(),
|
|
ObjectOptions {
|
|
no_lock: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => ensure_decommission_start_rebalance_meta_allowed(Some(&rebalance_meta))?,
|
|
Err(Error::ConfigNotFound) => {}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"rebalance metadata load before decommission start save failed: {err}"
|
|
)));
|
|
}
|
|
}
|
|
|
|
let current_pool_meta = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
pool_meta.clone()
|
|
};
|
|
let mut latest_pool_meta = PoolMeta::default();
|
|
let replica_state = latest_pool_meta
|
|
.load_no_lock_from_replicas_observing(self.pools.clone(), &mut save_guard)
|
|
.await?;
|
|
save_guard.observe_replicas(replica_state);
|
|
save_guard.ensure_write_safe("decommission start failed")?;
|
|
if latest_pool_meta.pools.is_empty() {
|
|
latest_pool_meta = current_pool_meta;
|
|
}
|
|
|
|
ensure_decommission_start_pool_states(&latest_pool_meta, indices)?;
|
|
ensure_decommission_ledger_persistence_supported(&latest_pool_meta)?;
|
|
ensure_decommission_capacity_writer_supported(&latest_pool_meta)?;
|
|
let capacity_infos = self.get_decommission_all_pool_capacity_infos().await?;
|
|
activation_fence.ensure_held()?;
|
|
let target_fence_proof = crate::services::notification_sys::acquire_decommission_target_fence_fleet_proof();
|
|
|
|
let previous_pool_meta = latest_pool_meta.clone();
|
|
let capacity_generation = next_decommission_capacity_generation(&latest_pool_meta)?;
|
|
let first_idx = indices.first().copied();
|
|
let now = OffsetDateTime::now_utc();
|
|
for idx in indices.iter().copied() {
|
|
let capacity = capacity_infos
|
|
.iter()
|
|
.find(|capacity| capacity.pool_index == idx)
|
|
.ok_or_else(|| Error::DecommissionCapacity(format!("decommission capacity snapshot is missing pool {idx}")))?;
|
|
latest_pool_meta.set_decommission_state_at(
|
|
idx,
|
|
capacity.space,
|
|
Some(idx) != first_idx,
|
|
now,
|
|
Some(&rebalance_meta),
|
|
)?;
|
|
latest_pool_meta.queue_buckets(idx, decom_buckets.clone());
|
|
}
|
|
let model_version = select_decommission_capacity_model(&latest_pool_meta, target_fence_proof.is_some())?;
|
|
if model_version == DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION {
|
|
activation_fence.set_decommission_target_fence_proof(target_fence_proof);
|
|
}
|
|
reserve_decommission_start_target_capacity(
|
|
&mut latest_pool_meta,
|
|
indices,
|
|
&capacity_infos,
|
|
uuid::Uuid::new_v4(),
|
|
capacity_generation,
|
|
now,
|
|
model_version,
|
|
)?;
|
|
|
|
activation_fence.ensure_held()?;
|
|
let outcome = latest_pool_meta
|
|
.save_no_lock_with_activation_fence(self.pools.clone(), &mut save_guard, &activation_fence, indices)
|
|
.await?;
|
|
activation_fence.ensure_held()?;
|
|
{
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
publish_pool_meta_updates(&mut pool_meta, &outcome.committed, indices);
|
|
}
|
|
activation_fence.ensure_held()?;
|
|
outcome.disarm();
|
|
|
|
Ok(previous_pool_meta)
|
|
}
|
|
|
|
async fn rollback_decommission_start_after_reload_failure(
|
|
&self,
|
|
movement_gate: &Arc<tokio::sync::RwLock<()>>,
|
|
previous_pool_meta: &PoolMeta,
|
|
indices: &[usize],
|
|
) -> Result<()> {
|
|
let _movement_guard = movement_gate.write().await;
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (pool_meta_guard, mut snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "decommission start rollback failed")
|
|
.await?;
|
|
rollback_start_decommission_pool_meta(&mut snapshot, previous_pool_meta, indices);
|
|
let outcome = snapshot
|
|
.save_no_lock_armed(self.pools.clone(), &mut save_guard, pool_meta_guard.lock_lost_signal(), indices)
|
|
.await?;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "decommission start rollback failed")?;
|
|
publish_pool_meta_updates(&mut pool_meta, &outcome.committed, indices);
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "decommission start rollback failed")?;
|
|
drop(pool_meta);
|
|
outcome.disarm();
|
|
self.ctx.advance_data_movement_operation_epoch();
|
|
Ok(())
|
|
}
|
|
|
|
async fn ensure_decommission_rebalance_idle_after_refresh(&self) -> Result<()> {
|
|
self.load_rebalance_meta().await?;
|
|
ensure_decommission_not_rebalancing(self.is_rebalance_conflicting_with_decommission().await)
|
|
}
|
|
|
|
async fn ensure_decommission_rebalance_idle_after_refresh_under_start_gate(&self) -> Result<()> {
|
|
self.load_rebalance_meta_under_start_gate().await?;
|
|
ensure_decommission_not_rebalancing(self.is_rebalance_conflicting_with_decommission().await)
|
|
}
|
|
|
|
pub async fn status(&self, idx: usize) -> Result<PoolStatus> {
|
|
let capacity_infos = self.get_decommission_all_pool_capacity_infos().await?;
|
|
let space_info = capacity_infos
|
|
.iter()
|
|
.find(|capacity| capacity.pool_index == idx)
|
|
.map(|capacity| capacity.space)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(self.pools.len(), idx))?;
|
|
|
|
let pool_meta = self.pool_meta.read().await;
|
|
let active_target_reservations = active_decommission_target_reservations(&pool_meta);
|
|
|
|
let mut pool_info = get_by_index(pool_meta.pools.as_slice(), idx, "fetch decommission status")?.clone();
|
|
if let Some(info) = pool_info.decommission.as_mut() {
|
|
observe_decommission_capacity_reservation(info, &capacity_infos, &active_target_reservations);
|
|
}
|
|
Ok(apply_decommission_status_space_info(pool_info, space_info))
|
|
}
|
|
|
|
#[tracing::instrument(skip_all)]
|
|
pub async fn refresh_pool_status_meta(&self) -> Result<()> {
|
|
let movement_gate = self.ctx.data_movement_operation_gate();
|
|
let _movement_guard = movement_gate.write().await;
|
|
let persisted = self.load_runtime_pool_meta("refresh pool status metadata failed").await?;
|
|
|
|
let active_workers = {
|
|
let cancelers = self.decommission_cancelers.read().await;
|
|
cancelers
|
|
.iter()
|
|
.map(|canceler| canceler.as_ref().is_some_and(DecommissionCanceler::is_active))
|
|
.collect::<Vec<_>>()
|
|
};
|
|
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
if merge_pool_status_refresh(&mut pool_meta, persisted, &active_workers) {
|
|
self.ctx.advance_data_movement_operation_epoch();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_decommission_pool_capacity_info(&self, idx: usize) -> Result<DecommissionPoolCapacityInfo> {
|
|
if let Some(sets) = self.pools.get(idx) {
|
|
let mut info = sets.storage_info_snapshot().await;
|
|
info.backend = StorageAdminApi::backend_info(self).await;
|
|
|
|
let total = get_total_usable_capacity(&info.disks, &info);
|
|
let free = get_total_usable_capacity_free(&info.disks, &info);
|
|
let space = PoolSpaceInfo {
|
|
free,
|
|
total,
|
|
used: total.saturating_sub(free),
|
|
};
|
|
let layout = DecommissionErasureLayout {
|
|
data: info
|
|
.backend
|
|
.standard_sc_data
|
|
.get(idx)
|
|
.copied()
|
|
.unwrap_or_else(|| sets.set_drive_count.saturating_sub(sets.parity_count)),
|
|
parity: info
|
|
.backend
|
|
.standard_sc_parities
|
|
.get(idx)
|
|
.copied()
|
|
.unwrap_or(sets.parity_count),
|
|
};
|
|
if !layout.is_valid() {
|
|
return Err(Error::DecommissionCapacity(format!(
|
|
"failed to read decommission capacity for pool {idx}: invalid erasure layout data={} parity={}",
|
|
layout.data, layout.parity
|
|
)));
|
|
}
|
|
let (physical_total, physical_free, physical_used) =
|
|
decommission_physical_pool_capacity(&info.disks, idx, layout, space);
|
|
|
|
Ok(DecommissionPoolCapacityInfo {
|
|
pool_index: idx,
|
|
space,
|
|
layout,
|
|
physical_free,
|
|
physical_total,
|
|
physical_used,
|
|
})
|
|
} else {
|
|
Err(invalid_decommission_pool_index_error(self.pools.len(), idx))
|
|
}
|
|
}
|
|
|
|
async fn get_decommission_all_pool_capacity_infos(&self) -> Result<Vec<DecommissionPoolCapacityInfo>> {
|
|
#[cfg(test)]
|
|
if let Some(capacity_infos) = take_decommission_capacity_info_override_for_test(self.id) {
|
|
return Ok(capacity_infos);
|
|
}
|
|
|
|
let mut capacity_infos = Vec::with_capacity(self.pools.len());
|
|
for idx in 0..self.pools.len() {
|
|
capacity_infos.push(self.get_decommission_pool_capacity_info(idx).await?);
|
|
}
|
|
Ok(capacity_infos)
|
|
}
|
|
|
|
async fn ensure_decommission_runtime_capacity_available(&self, idx: usize, generation: OffsetDateTime) -> Result<()> {
|
|
let capacity_infos = self.get_decommission_all_pool_capacity_infos().await?;
|
|
let pool_meta = self.pool_meta.read().await;
|
|
ensure_decommission_generation(&pool_meta, idx, generation)?;
|
|
#[cfg(test)]
|
|
if pool_meta
|
|
.pools
|
|
.get(idx)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.is_some_and(|info| info.capacity_reservation.is_none())
|
|
{
|
|
return Ok(());
|
|
}
|
|
ensure_decommission_capacity_reservations_available(&pool_meta, &capacity_infos, "migration")
|
|
}
|
|
|
|
async fn decommission_capacity_owner_for_worker(
|
|
&self,
|
|
idx: usize,
|
|
generation: OffsetDateTime,
|
|
) -> Result<Option<DecommissionCapacityOwner>> {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
ensure_decommission_generation(&pool_meta, idx, generation)?;
|
|
let Some(reservation) = pool_meta
|
|
.pools
|
|
.get(idx)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| reservation.lease_active_at(OffsetDateTime::now_utc()))
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
Ok(Some(DecommissionCapacityOwner {
|
|
source_pool_index: idx,
|
|
operation_id: reservation.operation_id,
|
|
generation: reservation.generation,
|
|
owner_nonce: reservation.owner_nonce,
|
|
mutation_id: None,
|
|
}))
|
|
}
|
|
|
|
pub(crate) async fn select_decommission_capacity_target_pool(
|
|
&self,
|
|
owner: DecommissionCapacityOwner,
|
|
expected_data_bytes: usize,
|
|
) -> Result<usize> {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
let reservation = pool_meta
|
|
.pools
|
|
.get(owner.source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| reservation.admits_owner(owner, OffsetDateTime::now_utc()))
|
|
.ok_or_else(|| decommission_capacity_blocked_error("decommission target selection reservation is stale"))?;
|
|
let candidate = |target: &DecommissionCapacityTarget| {
|
|
let expected_physical_bytes = capacity_target_physical_bytes(expected_data_bytes.max(1), target.layout).ok()?;
|
|
let required_peak = expected_physical_bytes.saturating_mul(1usize.saturating_add(reservation.temporary_copies));
|
|
let remaining = target.remaining_reserved_physical_bytes(reservation.temporary_copies);
|
|
(required_peak <= remaining).then_some((target.pool_index, remaining))
|
|
};
|
|
if let Some(permitted_target_pool_index) = decommission_capacity_target_permit_index(self.id, owner) {
|
|
if let Some((pool_index, _)) = reservation
|
|
.targets
|
|
.iter()
|
|
.find(|target| target.pool_index == permitted_target_pool_index)
|
|
.and_then(&candidate)
|
|
{
|
|
return Ok(pool_index);
|
|
}
|
|
// The holder that preceded this waiter may have consumed the
|
|
// remaining allocation. Release that guard before selecting a
|
|
// different target so one mutation never holds two target gates.
|
|
discard_decommission_capacity_target_permit_except(self.id, owner, None);
|
|
}
|
|
reservation
|
|
.targets
|
|
.iter()
|
|
.filter_map(candidate)
|
|
.max_by_key(|(_, remaining)| *remaining)
|
|
.map(|(pool_index, _)| pool_index)
|
|
.ok_or_else(|| {
|
|
decommission_capacity_blocked_error(format!(
|
|
"source pool {} has no target allocation for {expected_data_bytes} data bytes",
|
|
owner.source_pool_index
|
|
))
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn next_scanner_data_movement_update(&self, now: OffsetDateTime) -> OffsetDateTime {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
let rebalance_meta = self.rebalance_meta.read().await;
|
|
pool_meta.next_scanner_data_movement_update(now, rebalance_meta.as_ref())
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn decommission_cancel(self: &Arc<Self>, idx: usize) -> Result<()> {
|
|
self.decommission_cancel_with_owner(idx, None).await
|
|
}
|
|
|
|
async fn decommission_cancel_for_operation(self: &Arc<Self>, idx: usize, owner: &DecommissionCanceler) -> Result<()> {
|
|
self.decommission_cancel_with_owner(idx, Some(owner)).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn decommission_cancel_with_owner_and_save<Save, SaveFuture>(
|
|
self: &Arc<Self>,
|
|
idx: usize,
|
|
owner: Option<&DecommissionCanceler>,
|
|
save_pool_meta: Save,
|
|
) -> Result<()>
|
|
where
|
|
Save: FnOnce(PoolMeta, Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>) -> SaveFuture + Send + 'static,
|
|
SaveFuture: Future<Output = Result<()>> + Send + 'static,
|
|
{
|
|
let store = self.clone();
|
|
let owner = owner.cloned();
|
|
// Dropping the RPC waiter detaches this task; the transaction retains
|
|
// the store and exact owner until persistence is resolved.
|
|
tokio::spawn(async move { store.decommission_cancel_transaction(idx, owner, false, save_pool_meta).await })
|
|
.await
|
|
.map_err(|err| Error::other(format!("decommission cancel transaction task join error: {err}")))?
|
|
}
|
|
|
|
async fn decommission_cancel_transaction<Save, SaveFuture>(
|
|
&self,
|
|
idx: usize,
|
|
owner: Option<DecommissionCanceler>,
|
|
acquire_runtime_fence: bool,
|
|
save_pool_meta: Save,
|
|
) -> Result<()>
|
|
where
|
|
Save: FnOnce(PoolMeta, Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>) -> SaveFuture,
|
|
SaveFuture: Future<Output = Result<()>>,
|
|
{
|
|
let owner = owner.as_ref();
|
|
ensure_decommission_terminal_operation_supported(self.single_pool(), "cancel decommission")?;
|
|
#[cfg(test)]
|
|
if acquire_runtime_fence {
|
|
pause_decommission_cancel_before_start_gate(self.id).await;
|
|
}
|
|
let _start_guard = self.start_gate.lock().await;
|
|
// Read the fence model from durable metadata without retaining the
|
|
// global lock, then fence the exact target cohort before taking the
|
|
// write lock used to publish the terminal transition.
|
|
let terminal_fence_plan = if acquire_runtime_fence {
|
|
let mut read_save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (read_guard, snapshot) = self
|
|
.acquire_pool_meta_read_guard(&mut read_save_guard, "decommission cancel fence planning failed")
|
|
.await?;
|
|
let plan = decommission_capacity_terminal_fence_plan(&snapshot, idx)?;
|
|
drop(read_guard);
|
|
drop(read_save_guard);
|
|
plan
|
|
} else {
|
|
None
|
|
};
|
|
let _capacity_target_guards = if acquire_runtime_fence {
|
|
self.acquire_decommission_capacity_terminal_guards(terminal_fence_plan.as_ref())
|
|
.await?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (_pool_meta_guard, mut persisted_pool_meta) = if acquire_runtime_fence {
|
|
let (guard, pool_meta) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "decommission cancel failed")
|
|
.await?;
|
|
(Some(guard), Some(pool_meta))
|
|
} else {
|
|
save_guard.ensure_write_safe("decommission cancel failed")?;
|
|
(None, None)
|
|
};
|
|
if let Some(persisted_pool_meta) = persisted_pool_meta.as_ref() {
|
|
let committed_plan = decommission_capacity_terminal_fence_plan(persisted_pool_meta, idx)?;
|
|
if committed_plan != terminal_fence_plan {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"decommission capacity owner or target cohort changed while acquiring terminal fences",
|
|
));
|
|
}
|
|
}
|
|
let pool_meta_fence = _pool_meta_guard
|
|
.as_ref()
|
|
.and_then(rustfs_lock::NamespaceLockGuard::lock_lost_signal);
|
|
|
|
// Lock order: start gate, target gates, save gate, distributed pool
|
|
// metadata fence, rebalance_meta, decommission_cancelers, then
|
|
// pool_meta. The state guards stay held across persistence so the
|
|
// active generation cannot change before the cancel is published.
|
|
let rebalance_meta = self.rebalance_meta.read().await.clone();
|
|
let terminal_at = OffsetDateTime::now_utc();
|
|
let mut cancelers = self.decommission_cancelers.write().await;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
if acquire_runtime_fence {
|
|
let local_plan = decommission_capacity_terminal_fence_plan(&pool_meta, idx)?;
|
|
if local_plan != terminal_fence_plan {
|
|
return Err(decommission_capacity_blocked_error(
|
|
"local decommission capacity owner differs from the durable terminal fence plan",
|
|
));
|
|
}
|
|
}
|
|
let (pending, should_reload_pool_meta, already_canceled, terminal_canceler, durable_movement_generation) = {
|
|
let mut already_canceled = false;
|
|
let (pool_present, decommission_present, terminal) = if let Some(pool) = pool_meta.pools.get(idx) {
|
|
if let Some(info) = pool.decommission.as_ref() {
|
|
already_canceled = info.canceled;
|
|
(
|
|
true,
|
|
info.has_decommission_state(),
|
|
should_reject_decommission_cancel_as_terminal(info.complete, info.failed),
|
|
)
|
|
} else {
|
|
(true, false, false)
|
|
}
|
|
} else {
|
|
(false, false, false)
|
|
};
|
|
|
|
ensure_decommission_cancel_allowed(pool_present, decommission_present, terminal)?;
|
|
let previous_pool = pool_meta
|
|
.pools
|
|
.get(idx)
|
|
.cloned()
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_meta.pools.len(), idx))?;
|
|
let previous_decommission = previous_pool
|
|
.decommission
|
|
.as_ref()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("cancel decommission"))?;
|
|
let mut snapshot = pool_meta.clone();
|
|
let Some(changed) = update_decommission_for_operation(cancelers.as_slice(), &mut snapshot, idx, owner, |pool_meta| {
|
|
pool_meta.decommission_cancel_at(idx, terminal_at, rebalance_meta.as_ref())
|
|
}) else {
|
|
return Ok(());
|
|
};
|
|
let pending = if changed {
|
|
let canceled_pool = snapshot
|
|
.pools
|
|
.get(idx)
|
|
.cloned()
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_meta.pools.len(), idx))?;
|
|
if let Some(persisted) = persisted_pool_meta.as_mut() {
|
|
merge_pool_meta_updates_for_save(persisted, &snapshot, &[idx], "decommission cancel failed")?;
|
|
snapshot = persisted.clone();
|
|
}
|
|
Some((
|
|
snapshot,
|
|
DecommissionCancelCommit {
|
|
previous_start_time: previous_decommission.start_time,
|
|
previous_queued: previous_decommission.queued,
|
|
previous_last_update: previous_pool.last_update,
|
|
canceled_pool,
|
|
},
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
let terminal_canceler = if let Some(owner) = owner {
|
|
Some(owner.clone())
|
|
} else {
|
|
cancelers.get(idx).and_then(Option::as_ref).cloned()
|
|
};
|
|
let durable_movement_generation = pending
|
|
.as_ref()
|
|
.map(|(_, commit)| crate::store::scanner_data_movement_timestamp_generation(commit.canceled_pool.last_update))
|
|
.unwrap_or_default();
|
|
(
|
|
pending,
|
|
should_retry_decommission_cancel_reload(changed, already_canceled),
|
|
already_canceled,
|
|
terminal_canceler,
|
|
durable_movement_generation,
|
|
)
|
|
};
|
|
let active_worker = terminal_canceler.as_ref().is_some_and(DecommissionCanceler::is_active);
|
|
if !active_worker && !already_canceled {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "cancel_skipped",
|
|
reason = "no_active_canceler",
|
|
"Decommission cancel skipped"
|
|
);
|
|
}
|
|
|
|
let changed = pending.is_some();
|
|
let commit_result = if let Some((snapshot, commit)) = pending {
|
|
if let Err(err) = save_pool_meta(snapshot, pool_meta_fence).await {
|
|
save_guard.block_writes();
|
|
return Err(err);
|
|
}
|
|
if let Some(pool_meta_guard) = _pool_meta_guard.as_ref()
|
|
&& let Err(err) = ensure_pool_meta_write_fence(pool_meta_guard, "decommission cancel failed")
|
|
{
|
|
save_guard.block_writes();
|
|
return Err(err);
|
|
}
|
|
commit_decommission_cancel(&mut pool_meta, idx, commit)
|
|
} else {
|
|
Ok(())
|
|
};
|
|
|
|
commit_result?;
|
|
if let Some(pool_meta_guard) = _pool_meta_guard.as_ref()
|
|
&& let Err(err) = ensure_pool_meta_write_fence(pool_meta_guard, "decommission cancel failed")
|
|
{
|
|
save_guard.block_writes();
|
|
return Err(err);
|
|
}
|
|
if let Some(canceler) = terminal_canceler.as_ref() {
|
|
take_and_cancel_decommission_canceler_for_operation(cancelers.as_mut_slice(), idx, canceler);
|
|
}
|
|
drop(pool_meta);
|
|
drop(cancelers);
|
|
drop(_pool_meta_guard);
|
|
drop(save_guard);
|
|
|
|
if changed {
|
|
// Persistence must commit before the cancellation signal. Wait for
|
|
// in-flight movement only after signaling so readers can release
|
|
// the shared gate without deadlocking the terminal transition.
|
|
let movement_gate = self.ctx.data_movement_operation_gate();
|
|
let _movement_guard = movement_gate.write().await;
|
|
self.ctx
|
|
.advance_data_movement_operation_epoch_to_durable_generation(durable_movement_generation);
|
|
}
|
|
|
|
if should_reload_pool_meta && let Some(notification_sys) = runtime_sources::notification_sys() {
|
|
let stage = format!("decommission_cancel for pool {idx}");
|
|
if let Err(err) =
|
|
resolve_decommission_pool_meta_reload_result(notification_sys.reload_pool_meta().await, stage.as_str())
|
|
{
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "terminal_reload_failed",
|
|
error = %err,
|
|
"Decommission cancel saved locally but pool meta reload failed"
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn release_decommission_canceler_slot(&self, idx: usize, owner: &DecommissionCanceler) {
|
|
let mut cancelers = self.decommission_cancelers.write().await;
|
|
take_and_cancel_decommission_canceler_for_operation(cancelers.as_mut_slice(), idx, owner);
|
|
}
|
|
|
|
async fn decommission_terminal_retryable_for_operation(&self, idx: usize, owner: &DecommissionCanceler) -> bool {
|
|
let _start_guard = self.start_gate.lock().await;
|
|
let mut cancelers = self.decommission_cancelers.write().await;
|
|
if !decommission_canceler_is_owned_by(cancelers.as_slice(), idx, owner) {
|
|
owner.release();
|
|
return false;
|
|
}
|
|
|
|
let retryable = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
pool_meta
|
|
.pools
|
|
.get(idx)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.is_some_and(|info| info.has_decommission_state() && !info.complete && !info.failed && !info.canceled)
|
|
};
|
|
if !retryable {
|
|
take_and_cancel_decommission_canceler_for_operation(cancelers.as_mut_slice(), idx, owner);
|
|
}
|
|
retryable
|
|
}
|
|
|
|
async fn retry_decommission_cancel_for_operation(self: &Arc<Self>, idx: usize, owner: &DecommissionCanceler) {
|
|
if !self.decommission_terminal_retryable_for_operation(idx, owner).await {
|
|
return;
|
|
}
|
|
|
|
let mut attempt = 0usize;
|
|
loop {
|
|
if self
|
|
.ensure_pool_meta_side_effects_safe("decommission cancel retry paused because pool metadata requires recovery")
|
|
.await
|
|
.is_err()
|
|
{
|
|
self.release_decommission_canceler_slot(idx, owner).await;
|
|
return;
|
|
}
|
|
let Err(err) = self.decommission_cancel_for_operation(idx, owner).await else {
|
|
return;
|
|
};
|
|
if !self.decommission_terminal_retryable_for_operation(idx, owner).await {
|
|
return;
|
|
}
|
|
attempt += 1;
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "terminal_save_retry",
|
|
terminal = "canceled",
|
|
attempt,
|
|
error = %err,
|
|
"Decommission terminal save will be retried"
|
|
);
|
|
tokio::time::sleep(DECOMMISSION_TERMINAL_RETRY_DELAY).await;
|
|
}
|
|
}
|
|
|
|
async fn retry_decommission_failed_for_operation(&self, idx: usize, owner: &DecommissionCanceler) {
|
|
let mut attempt = 0usize;
|
|
loop {
|
|
if self
|
|
.ensure_pool_meta_side_effects_safe("decommission failure retry paused because pool metadata requires recovery")
|
|
.await
|
|
.is_err()
|
|
{
|
|
self.release_decommission_canceler_slot(idx, owner).await;
|
|
return;
|
|
}
|
|
let Err(err) = self.decommission_failed_for_operation(idx, owner).await else {
|
|
return;
|
|
};
|
|
if !self.decommission_terminal_retryable_for_operation(idx, owner).await {
|
|
return;
|
|
}
|
|
attempt += 1;
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "terminal_save_retry",
|
|
terminal = "failed",
|
|
attempt,
|
|
error = %err,
|
|
"Decommission terminal save will be retried"
|
|
);
|
|
tokio::time::sleep(DECOMMISSION_TERMINAL_RETRY_DELAY).await;
|
|
}
|
|
}
|
|
|
|
async fn decommission_cancel_with_owner(self: &Arc<Self>, idx: usize, owner: Option<&DecommissionCanceler>) -> Result<()> {
|
|
let pools = self.pools.clone();
|
|
let store = self.clone();
|
|
let owner = owner.cloned();
|
|
tokio::spawn(async move {
|
|
store
|
|
.decommission_cancel_transaction(idx, owner, true, move |snapshot, pool_meta_fence| async move {
|
|
snapshot.save_no_lock_with_fence(pools, pool_meta_fence, &[idx]).await
|
|
})
|
|
.await
|
|
})
|
|
.await
|
|
.map_err(|err| Error::other(format!("decommission cancel transaction task join error: {err}")))?
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn clear_decommission_with_save<Save, SaveFuture>(self: &Arc<Self>, idx: usize, save_pool_meta: Save) -> Result<()>
|
|
where
|
|
Save: FnOnce() -> SaveFuture + Send + 'static,
|
|
SaveFuture: Future<Output = Result<()>> + Send + 'static,
|
|
{
|
|
let store = self.clone();
|
|
tokio::spawn(async move { store.clear_decommission_transaction(idx, save_pool_meta).await })
|
|
.await
|
|
.map_err(|err| Error::other(format!("clear decommission transaction task join error: {err}")))?
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn clear_decommission(self: &Arc<Self>, idx: usize) -> Result<()> {
|
|
let store = self.clone();
|
|
let save_store = store.clone();
|
|
// Dropping the RPC waiter detaches this task; once in-memory state can
|
|
// change, the transaction must persist or roll it back before ending.
|
|
tokio::spawn(async move {
|
|
store
|
|
.clear_decommission_transaction(idx, move || async move { save_store.save_current_pool_meta(&[idx]).await })
|
|
.await
|
|
})
|
|
.await
|
|
.map_err(|err| Error::other(format!("clear decommission transaction task join error: {err}")))?
|
|
}
|
|
|
|
async fn clear_decommission_transaction<Save, SaveFuture>(&self, idx: usize, save_pool_meta: Save) -> Result<()>
|
|
where
|
|
Save: FnOnce() -> SaveFuture,
|
|
SaveFuture: Future<Output = Result<()>>,
|
|
{
|
|
ensure_decommission_terminal_operation_supported(self.single_pool(), "clear decommission")?;
|
|
let _start_guard = self.start_gate.lock().await;
|
|
|
|
{
|
|
let pool_meta = self.pool_meta.read().await;
|
|
let pool_count = pool_meta.pools.len();
|
|
ensure_valid_decommission_pool_index(pool_count, idx)?;
|
|
let Some(pool) = pool_meta.pools.get(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_count, idx));
|
|
};
|
|
let (decommission_present, complete, failed, canceled, unresolved_entries) = pool
|
|
.decommission
|
|
.as_ref()
|
|
.map(|info| {
|
|
(
|
|
info.has_decommission_state(),
|
|
info.complete,
|
|
info.failed,
|
|
info.canceled,
|
|
info.unresolved_entries.len(),
|
|
)
|
|
})
|
|
.unwrap_or((false, false, false, false, 0));
|
|
ensure_decommission_clear_allowed(true, decommission_present, complete, failed, canceled, unresolved_entries)?;
|
|
}
|
|
// Cancel workers before waiting for the movement writer so active
|
|
// object operations can observe the signal and release read guards.
|
|
self.cancel_decommission_routines(&[idx]).await;
|
|
let movement_gate = self.ctx.data_movement_operation_gate();
|
|
let _movement_guard = movement_gate.write().await;
|
|
|
|
let (should_reload_pool_meta, previous_pool_meta) = {
|
|
let rebalance_meta = self.rebalance_meta.read().await.clone();
|
|
let terminal_at = OffsetDateTime::now_utc();
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
let previous_pool_meta = pool_meta.clone();
|
|
let changed = pool_meta.clear_decommission_at(idx, terminal_at, rebalance_meta.as_ref())?;
|
|
(changed, changed.then_some(previous_pool_meta))
|
|
};
|
|
|
|
if should_reload_pool_meta && let Err(err) = save_pool_meta().await {
|
|
if let Some(previous_pool_meta) = previous_pool_meta {
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
rollback_decommission_pool_meta(&mut pool_meta, &previous_pool_meta, &[idx]);
|
|
}
|
|
return Err(err);
|
|
}
|
|
|
|
if should_reload_pool_meta {
|
|
self.ctx.advance_data_movement_operation_epoch();
|
|
}
|
|
drop(_movement_guard);
|
|
|
|
if should_reload_pool_meta && let Some(notification_sys) = runtime_sources::notification_sys() {
|
|
let stage = format!("clear_decommission for pool {idx}");
|
|
if let Err(err) =
|
|
resolve_decommission_pool_meta_reload_result(notification_sys.reload_pool_meta().await, stage.as_str())
|
|
{
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "terminal_reload_failed",
|
|
error = %err,
|
|
"Decommission clear saved locally but pool meta reload failed"
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn promote_queued_decommission(&self, idx: usize, owner: &DecommissionCanceler) -> Result<OffsetDateTime> {
|
|
// Serialize promotion and generation capture with clear/restart transitions.
|
|
let (changed, generation, save_error) = {
|
|
let _start_guard = self.start_gate.lock().await;
|
|
let movement_gate = self.ctx.data_movement_operation_gate();
|
|
let _movement_guard = movement_gate.write().await;
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (pool_meta_guard, mut snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "decommission promotion failed")
|
|
.await?;
|
|
let rebalance_meta = self.rebalance_meta.read().await.clone();
|
|
let capacity_infos = if self.pools.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
self.get_decommission_all_pool_capacity_infos().await?
|
|
};
|
|
let target_fence_proof = crate::services::notification_sys::acquire_decommission_target_fence_fleet_proof();
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
if pool_meta.pools.get(idx).is_none() {
|
|
return Err(Error::other("failed to start decommission: target pool was not found"));
|
|
}
|
|
let capacity_recovery_needed = active_decommission_source_indices(&pool_meta).into_iter().any(|source_idx| {
|
|
pool_meta
|
|
.pools
|
|
.get(source_idx)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.is_none_or(|reservation| !reservation.active())
|
|
});
|
|
let capacity_indices = if capacity_infos.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
recover_decommission_capacity_reservations(
|
|
&mut pool_meta,
|
|
&capacity_infos,
|
|
OffsetDateTime::now_utc(),
|
|
target_fence_proof.is_some(),
|
|
)?
|
|
};
|
|
let target_fence_proof_required = capacity_recovery_needed
|
|
&& active_decommission_capacity_model(&pool_meta)? == Some(DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION);
|
|
ensure_decommission_target_fence_fleet_proof(target_fence_proof.as_ref(), target_fence_proof_required)?;
|
|
let reconciled = reconcile_decommission_meta_buckets(&mut pool_meta, idx);
|
|
let promoted = pool_meta.promote_queued_decommission_at(idx, OffsetDateTime::now_utc(), rebalance_meta.as_ref());
|
|
let mut changed_indices = capacity_indices;
|
|
let changed = !changed_indices.is_empty() || reconciled || promoted;
|
|
if changed {
|
|
if !changed_indices.contains(&idx) {
|
|
changed_indices.push(idx);
|
|
}
|
|
merge_pool_meta_updates_for_save(&mut snapshot, &pool_meta, &changed_indices, "decommission promotion failed")?;
|
|
}
|
|
drop(pool_meta);
|
|
|
|
let (save_outcome, save_error) = if changed {
|
|
ensure_decommission_target_fence_fleet_proof(target_fence_proof.as_ref(), target_fence_proof_required)?;
|
|
match snapshot
|
|
.save_no_lock_armed(self.pools.clone(), &mut save_guard, pool_meta_guard.lock_lost_signal(), &changed_indices)
|
|
.await
|
|
{
|
|
Ok(outcome) => (Some(outcome), None),
|
|
Err(err) => (None, Some(err)),
|
|
}
|
|
} else {
|
|
(None, None)
|
|
};
|
|
let generation = self.active_decommission_generation(idx).await?;
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "decommission promotion failed")?;
|
|
ensure_decommission_target_fence_fleet_proof(target_fence_proof.as_ref(), target_fence_proof_required)?;
|
|
if let Some(outcome) = save_outcome {
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
pool_meta.version = pool_meta.version.max(outcome.committed.version);
|
|
drop(pool_meta);
|
|
outcome.disarm();
|
|
}
|
|
(changed, generation, save_error)
|
|
};
|
|
|
|
if let Some(err) = save_error {
|
|
resolve_decommission_terminal_mark_after_error_result(
|
|
self.decommission_failed_for_operation(idx, owner).await,
|
|
idx,
|
|
&err,
|
|
)?;
|
|
return Err(err);
|
|
}
|
|
|
|
if changed {
|
|
self.ctx.advance_data_movement_operation_epoch();
|
|
}
|
|
if changed && let Some(notification_sys) = runtime_sources::notification_sys() {
|
|
let stage = format!("promote_queued_decommission for pool {idx}");
|
|
if let Err(err) =
|
|
resolve_decommission_pool_meta_reload_result(notification_sys.reload_pool_meta().await, stage.as_str())
|
|
{
|
|
resolve_decommission_terminal_mark_after_error_result(
|
|
self.decommission_failed_for_operation(idx, owner).await,
|
|
idx,
|
|
&err,
|
|
)?;
|
|
return Err(err);
|
|
}
|
|
}
|
|
|
|
Ok(generation)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn promote_queued_decommission_for_test(&self, idx: usize) -> Result<()> {
|
|
let owner = DecommissionCanceler::new(CancellationToken::new());
|
|
self.promote_queued_decommission(idx, &owner).await?;
|
|
let mut cancelers = self.decommission_cancelers.write().await;
|
|
if let Some(slot) = cancelers.get_mut(idx)
|
|
&& let Some(previous) = slot.replace(owner)
|
|
{
|
|
previous.release();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn record_decommission_terminal_reload_failure(&self, idx: usize, stage: &str, err: Error) -> Result<()> {
|
|
let movement_gate = self.ctx.data_movement_operation_gate();
|
|
let _movement_guard = movement_gate.write().await;
|
|
let changed = {
|
|
let rebalance_meta = self.rebalance_meta.read().await.clone();
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
pool_meta.record_decommission_terminal_reload_failure_at(
|
|
idx,
|
|
stage,
|
|
err.to_string(),
|
|
OffsetDateTime::now_utc(),
|
|
rebalance_meta.as_ref(),
|
|
)?
|
|
};
|
|
|
|
if changed {
|
|
self.save_current_pool_meta(&[idx]).await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn pause_decommission_for_capacity(&self, idx: usize, err: &Error) -> Result<()> {
|
|
let mut save_guard = self.pool_meta_save_gate.lock().await;
|
|
let (pool_meta_guard, mut snapshot) = self
|
|
.acquire_pool_meta_write_guard(&mut save_guard, "decommission capacity pause failed")
|
|
.await?;
|
|
snapshot.mark_decommission_capacity_blocked(idx, err.to_string(), OffsetDateTime::now_utc())?;
|
|
let outcome = snapshot
|
|
.save_no_lock_armed(self.pools.clone(), &mut save_guard, pool_meta_guard.lock_lost_signal(), &[idx])
|
|
.await?;
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "decommission capacity pause failed")?;
|
|
{
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
publish_pool_meta_updates(&mut pool_meta, &outcome.committed, &[idx]);
|
|
}
|
|
ensure_pool_meta_write_fence(&pool_meta_guard, "decommission capacity pause failed")?;
|
|
outcome.disarm();
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn is_decommission_running(&self) -> bool {
|
|
{
|
|
let cancelers = self.decommission_cancelers.read().await;
|
|
if has_active_decommission_canceler(cancelers.as_slice()) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
let pool_meta = self.pool_meta.read().await;
|
|
for pool in pool_meta.pools.iter() {
|
|
if let Some(ref info) = pool.decommission
|
|
&& info.has_decommission_state()
|
|
&& !info.complete
|
|
&& !info.failed
|
|
&& !info.canceled
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
async fn decommission_cancel_requested(&self, idx: usize, rx: &CancellationToken) -> bool {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
is_decommission_cancel_requested(rx.is_cancelled(), pool_meta.pools.get(idx))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn cancel_decommission_routines_and_wait(&self, indices: &[usize]) {
|
|
self.cancel_decommission_routines(indices).await;
|
|
let movement_gate = self.ctx.data_movement_operation_gate();
|
|
let _movement_guard = movement_gate.write().await;
|
|
}
|
|
|
|
async fn cancel_decommission_routines(&self, indices: &[usize]) {
|
|
{
|
|
let mut cancelers = self.decommission_cancelers.write().await;
|
|
for idx in indices {
|
|
take_and_cancel_decommission_canceler(cancelers.as_mut_slice(), *idx);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn quiesce_decommission_worker_after_join_error(&self, canceler: &DecommissionCanceler) {
|
|
canceler.cancel();
|
|
let movement_gate = self.ctx.data_movement_operation_gate();
|
|
let _movement_guard = movement_gate.write().await;
|
|
}
|
|
|
|
async fn reserve_decommission_routines(
|
|
&self,
|
|
rx: &CancellationToken,
|
|
indices: &[usize],
|
|
) -> Result<Vec<(usize, DecommissionCancelerGuard)>> {
|
|
let indices = dedup_indices(indices);
|
|
if indices.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let _start_guard = self.start_gate.lock().await;
|
|
let save_guard = self.pool_meta_save_gate.lock().await;
|
|
save_guard.ensure_write_safe("decommission cannot be scheduled while pool metadata requires recovery")?;
|
|
let indices = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
let indices = resumable_decommission_queue_indices(&pool_meta)
|
|
.into_iter()
|
|
.filter(|idx| indices.contains(idx))
|
|
.collect::<Vec<_>>();
|
|
if !indices.is_empty() {
|
|
ensure_decommission_ledger_persistence_supported(&pool_meta)?;
|
|
}
|
|
indices
|
|
};
|
|
if indices.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let index_cancelers = {
|
|
let mut cancelers = self.decommission_cancelers.write().await;
|
|
let missing = missing_decommission_worker_prefix(indices.as_slice(), cancelers.as_slice());
|
|
if missing.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let bound = bind_missing_decommission_cancelers(missing.as_slice(), rx, cancelers.as_mut_slice());
|
|
let guards = guard_decommission_cancelers(bound);
|
|
ensure_decommission_routines_scheduled(guards.len(), missing.len())?;
|
|
guards
|
|
};
|
|
Ok(index_cancelers)
|
|
}
|
|
|
|
async fn reserve_missing_local_decommission_routines(
|
|
&self,
|
|
rx: &CancellationToken,
|
|
endpoints: &EndpointServerPools,
|
|
) -> Result<Vec<(usize, DecommissionCancelerGuard)>> {
|
|
let indices = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
resumable_decommission_queue_indices(&pool_meta)
|
|
};
|
|
let indices = local_decommission_queue_prefix(endpoints, &indices)?;
|
|
self.reserve_decommission_routines(rx, indices.as_slice()).await
|
|
}
|
|
|
|
pub async fn spawn_missing_local_decommission_routines(self: &Arc<Self>) -> Result<()> {
|
|
self.spawn_missing_local_decommission_routines_with_token(CancellationToken::new())
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn has_active_local_decommission_worker(&self) -> bool {
|
|
let cancelers = self.decommission_cancelers.read().await;
|
|
has_active_decommission_canceler(cancelers.as_slice())
|
|
}
|
|
|
|
pub(crate) async fn spawn_missing_local_decommission_routines_with_token(
|
|
self: &Arc<Self>,
|
|
rx: CancellationToken,
|
|
) -> Result<()> {
|
|
let endpoints = self.endpoints();
|
|
let index_cancelers = self.reserve_missing_local_decommission_routines(&rx, &endpoints).await?;
|
|
if index_cancelers.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
drop(spawn_decommission_index_cancelers(
|
|
self.clone(),
|
|
rx,
|
|
index_cancelers,
|
|
Arc::new(Semaphore::new(decommission_entry_concurrency_limit())),
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument(skip(self, rx))]
|
|
pub async fn decommission(&self, rx: CancellationToken, indices: Vec<usize>) -> Result<()> {
|
|
let indices = dedup_indices(&indices);
|
|
|
|
info!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_indices = ?indices,
|
|
state = "requested",
|
|
"Decommission requested"
|
|
);
|
|
validate_start_decommission_request(&indices, self.single_pool())?;
|
|
|
|
self.ensure_decommission_rebalance_idle_after_refresh().await?;
|
|
|
|
let store = require_decommission_store(runtime_sources::object_store_handle(), "start decommission")?;
|
|
let local_indices = local_decommission_queue_prefix(&self.endpoints(), &indices)?;
|
|
let index_cancelers = self
|
|
.start_decommission_with_routines(indices, &rx, local_indices.as_slice())
|
|
.await?;
|
|
drop(spawn_decommission_index_cancelers(
|
|
store,
|
|
rx,
|
|
index_cancelers,
|
|
Arc::new(Semaphore::new(decommission_entry_concurrency_limit())),
|
|
));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn active_decommission_generation(&self, idx: usize) -> Result<OffsetDateTime> {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
let Some(pool) = pool_meta.pools.get(idx) else {
|
|
return Err(invalid_decommission_pool_index_error(pool_meta.pools.len(), idx));
|
|
};
|
|
let Some(info) = pool.decommission.as_ref() else {
|
|
return Err(decommission_metadata_not_initialized_error("load decommission generation"));
|
|
};
|
|
let Some(generation) = info.start_time else {
|
|
return Err(Error::OperationCanceled);
|
|
};
|
|
ensure_decommission_generation(&pool_meta, idx, generation)?;
|
|
Ok(generation)
|
|
}
|
|
|
|
async fn ensure_decommission_generation_current(&self, idx: usize, generation: OffsetDateTime) -> Result<()> {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
ensure_decommission_generation(&pool_meta, idx, generation)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn decommission_entry_worker(
|
|
self: Arc<Self>,
|
|
rx: CancellationToken,
|
|
idx: usize,
|
|
set_idx: usize,
|
|
generation: OffsetDateTime,
|
|
bucket: String,
|
|
set: Arc<SetDisks>,
|
|
lifecycle_config: Option<BucketLifecycleConfiguration>,
|
|
object_lock_config: Option<ObjectLockConfiguration>,
|
|
replication_config: Option<(ReplicationConfiguration, OffsetDateTime)>,
|
|
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
|
source_changed_exhaustions: Arc<AtomicUsize>,
|
|
entry_budget: Arc<Semaphore>,
|
|
queue: Arc<tokio::sync::Mutex<mpsc::Receiver<QueuedDecommissionEntry>>>,
|
|
entry_error: Arc<tokio::sync::Mutex<Option<Error>>>,
|
|
) {
|
|
loop {
|
|
let queued = tokio::select! {
|
|
biased;
|
|
_ = rx.cancelled() => return,
|
|
item = async {
|
|
let mut queue = queue.lock().await;
|
|
queue.recv().await
|
|
} => item,
|
|
};
|
|
let Some(QueuedDecommissionEntry { entry, queue_permit }) = queued else {
|
|
return;
|
|
};
|
|
let object_name = entry.name.clone();
|
|
|
|
if entry_error.lock().await.is_some() {
|
|
drop(queue_permit);
|
|
continue;
|
|
}
|
|
|
|
if let Err(err) = self.ensure_decommission_generation_current(idx, generation).await {
|
|
if matches!(err, Error::OperationCanceled) {
|
|
rx.cancel();
|
|
} else {
|
|
record_decommission_entry_error(&entry_error, &rx, err).await;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if let Err(err) = backpressure::wait_for_data_movement_admission(DataMovementOperation::Decommission, idx, &rx).await
|
|
{
|
|
if matches!(err, Error::OperationCanceled) {
|
|
return;
|
|
}
|
|
error!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
set_index = set_idx,
|
|
bucket = %bucket,
|
|
object = %object_name,
|
|
state = "entry_admission_failed",
|
|
error = %err,
|
|
"Decommission entry admission failed"
|
|
);
|
|
record_decommission_entry_error(&entry_error, &rx, err).await;
|
|
return;
|
|
}
|
|
|
|
let entry_budget_permit = match tokio::select! {
|
|
biased;
|
|
_ = rx.cancelled() => return,
|
|
permit = entry_budget.clone().acquire_owned() => permit,
|
|
} {
|
|
Ok(permit) => permit,
|
|
Err(err) => {
|
|
let err = Error::other(format!("decommission entry budget permit acquire failed: {err}"));
|
|
error!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
set_index = set_idx,
|
|
bucket = %bucket,
|
|
object = %object_name,
|
|
state = "entry_budget_acquire_failed",
|
|
error = %err,
|
|
"Decommission entry budget permit acquire failed"
|
|
);
|
|
record_decommission_entry_error(&entry_error, &rx, err).await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let result = self
|
|
.decommission_entry(
|
|
rx.clone(),
|
|
idx,
|
|
generation,
|
|
entry,
|
|
bucket.clone(),
|
|
set.clone(),
|
|
lifecycle_config.clone(),
|
|
object_lock_config.clone(),
|
|
replication_config.clone(),
|
|
expected_bucket_incarnation_id,
|
|
Arc::clone(&source_changed_exhaustions),
|
|
)
|
|
.await;
|
|
drop(entry_budget_permit);
|
|
drop(queue_permit);
|
|
|
|
if let Err(err) = result {
|
|
error!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
set_index = set_idx,
|
|
bucket = %bucket,
|
|
object = %object_name,
|
|
state = "entry_failed",
|
|
error = %err,
|
|
"Decommission entry failed"
|
|
);
|
|
record_decommission_entry_error(&entry_error, &rx, err).await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn decommission_set(
|
|
self: Arc<Self>,
|
|
rx: CancellationToken,
|
|
idx: usize,
|
|
set_idx: usize,
|
|
generation: OffsetDateTime,
|
|
set: Arc<SetDisks>,
|
|
bi: DecomBucketInfo,
|
|
lifecycle_config: Option<BucketLifecycleConfiguration>,
|
|
object_lock_config: Option<ObjectLockConfiguration>,
|
|
replication_config: Option<(ReplicationConfiguration, OffsetDateTime)>,
|
|
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
|
source_changed_exhaustions: Arc<AtomicUsize>,
|
|
entry_budget: Arc<Semaphore>,
|
|
entry_error: Arc<tokio::sync::Mutex<Option<Error>>>,
|
|
) -> Result<()> {
|
|
let worker_count = DECOMMISSION_ENTRY_WORKERS_PER_SET;
|
|
let queue_capacity = decommission_entry_queue_capacity(worker_count);
|
|
let outstanding_capacity = queue_capacity.saturating_add(worker_count);
|
|
let outstanding = Arc::new(Semaphore::new(outstanding_capacity));
|
|
let (tx, rx_queue) = mpsc::channel(queue_capacity);
|
|
let queue = Arc::new(tokio::sync::Mutex::new(rx_queue));
|
|
|
|
let mut entry_workers = tokio::task::JoinSet::new();
|
|
for _ in 0..worker_count {
|
|
let this = self.clone();
|
|
let rx = rx.clone();
|
|
let bucket = bi.name.clone();
|
|
let set = set.clone();
|
|
let lifecycle_config = lifecycle_config.clone();
|
|
let object_lock_config = object_lock_config.clone();
|
|
let replication_config = replication_config.clone();
|
|
let source_changed_exhaustions = Arc::clone(&source_changed_exhaustions);
|
|
let queue = queue.clone();
|
|
let entry_budget = entry_budget.clone();
|
|
let entry_error = entry_error.clone();
|
|
entry_workers.spawn(async move {
|
|
this.decommission_entry_worker(
|
|
rx,
|
|
idx,
|
|
set_idx,
|
|
generation,
|
|
bucket,
|
|
set,
|
|
lifecycle_config,
|
|
object_lock_config,
|
|
replication_config,
|
|
expected_bucket_incarnation_id,
|
|
source_changed_exhaustions,
|
|
entry_budget,
|
|
queue,
|
|
entry_error,
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
let callback: ListCallback = Arc::new({
|
|
let tx = tx.clone();
|
|
let outstanding = outstanding.clone();
|
|
let callback_rx = rx.clone();
|
|
let entry_error = entry_error.clone();
|
|
let bucket = bi.name.clone();
|
|
move |entry: MetaCacheEntry| {
|
|
let tx = tx.clone();
|
|
let outstanding = outstanding.clone();
|
|
let callback_rx = callback_rx.clone();
|
|
let entry_error = entry_error.clone();
|
|
let bucket = bucket.clone();
|
|
Box::pin(async move {
|
|
if callback_rx.is_cancelled() || entry_error.lock().await.is_some() {
|
|
return;
|
|
}
|
|
|
|
if matches!(
|
|
enqueue_decommission_entry(&callback_rx, &outstanding, &tx, entry).await,
|
|
DecommissionEntryEnqueueResult::Closed
|
|
) {
|
|
let err = Error::other("decommission entry queue closed");
|
|
error!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
set_index = set_idx,
|
|
bucket = %bucket,
|
|
state = "entry_queue_closed",
|
|
error = %err,
|
|
"Decommission entry queue closed"
|
|
);
|
|
record_decommission_entry_error(&entry_error, &callback_rx, err).await;
|
|
}
|
|
})
|
|
}
|
|
});
|
|
|
|
let list_set = set.clone();
|
|
let list_rx = rx.clone();
|
|
let list_rx_for_list = list_rx.clone();
|
|
let list_rx_for_drain = list_rx.clone();
|
|
let list_bi = bi.clone();
|
|
let list_outstanding = outstanding.clone();
|
|
let list_entry_error = entry_error.clone();
|
|
let list_store = self.clone();
|
|
let mut listing = tokio::spawn(async move {
|
|
run_decommission_listing_with_retry_and_drain(
|
|
list_rx.clone(),
|
|
list_bi.name.clone(),
|
|
callback,
|
|
idx,
|
|
set_idx,
|
|
DECOMMISSION_LISTING_MAX_ATTEMPTS,
|
|
move |callback| {
|
|
let set = list_set.clone();
|
|
let rx = list_rx_for_list.clone();
|
|
let bucket = list_bi.clone();
|
|
let entry_error = list_entry_error.clone();
|
|
let store = list_store.clone();
|
|
async move {
|
|
set.list_objects_to_decommission(
|
|
store,
|
|
rx,
|
|
bucket,
|
|
callback,
|
|
entry_error,
|
|
idx,
|
|
set_idx,
|
|
generation,
|
|
false,
|
|
)
|
|
.await
|
|
}
|
|
},
|
|
move || {
|
|
let rx = list_rx_for_drain.clone();
|
|
let outstanding = list_outstanding.clone();
|
|
async move { drain_decommission_entry_queue(&rx, &outstanding, outstanding_capacity).await }
|
|
},
|
|
)
|
|
.await
|
|
});
|
|
|
|
let mut listing_result = None;
|
|
let mut workers_left = worker_count;
|
|
let mut sender = Some(tx);
|
|
while listing_result.is_none() || workers_left > 0 {
|
|
tokio::select! {
|
|
biased;
|
|
result = &mut listing, if listing_result.is_none() => {
|
|
let result = resolve_decommission_listing_worker_result(set_idx, result);
|
|
if result.is_err() {
|
|
rx.cancel();
|
|
}
|
|
listing_result = Some(result);
|
|
drop(sender.take());
|
|
}
|
|
worker_result = entry_workers.join_next(), if workers_left > 0 => {
|
|
workers_left -= 1;
|
|
if let Some(Err(err)) = worker_result {
|
|
let err = Error::other(format!("decommission entry worker {set_idx} task join error: {err}"));
|
|
error!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
set_index = set_idx,
|
|
bucket = %bi.name,
|
|
state = "entry_worker_join_failed",
|
|
error = %err,
|
|
"Decommission entry worker task failed"
|
|
);
|
|
record_decommission_entry_error(&entry_error, &rx, err).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let listing_result = listing_result.unwrap_or_else(|| Err(Error::other("decommission listing task did not complete")));
|
|
if let Some(err) = entry_error.lock().await.clone() {
|
|
return Err(err);
|
|
}
|
|
listing_result
|
|
}
|
|
|
|
async fn track_decommission_entry_progress_stage(
|
|
&self,
|
|
idx: usize,
|
|
generation: OffsetDateTime,
|
|
bucket: &str,
|
|
object: &str,
|
|
stage: &'static str,
|
|
) -> Result<()> {
|
|
{
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
ensure_decommission_generation(&pool_meta, idx, generation)?;
|
|
track_decommission_current_object_stage(&mut pool_meta, idx, bucket, object, stage)
|
|
.map_err(|err| with_decommission_entry_context(stage, bucket, object, err))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn wait_decommission_target_gate_retry(
|
|
&self,
|
|
rx: &CancellationToken,
|
|
idx: usize,
|
|
generation: OffsetDateTime,
|
|
target_pool_index: usize,
|
|
capacity_owner: Option<DecommissionCapacityOwner>,
|
|
set: &SetDisks,
|
|
entry: &MetaCacheEntry,
|
|
bucket: &str,
|
|
expected_version: &FileInfo,
|
|
target_busy_attempt: usize,
|
|
) -> Result<Option<DecommissionCapacityTargetPermit>> {
|
|
#[cfg(test)]
|
|
notify_decommission_target_gate_retry(self.id);
|
|
let mut wait_attempt = target_busy_attempt;
|
|
let target_guard = loop {
|
|
let retry_delay = decommission_retry_backoff_delay(
|
|
DECOMMISSION_SOURCE_CLEANUP_RETRY_DELAY,
|
|
wait_attempt.min(DECOMMISSION_CAPACITY_INTENT_CONFLICT_MAX_ATTEMPTS),
|
|
);
|
|
if wait_decommission_retry_backoff(rx, retry_delay).await {
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
}
|
|
if self.decommission_cancel_requested(idx, rx).await {
|
|
rx.cancel();
|
|
}
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
self.ensure_decommission_generation_current(idx, generation).await?;
|
|
|
|
match self.acquire_decommission_capacity_target_guard(target_pool_index).await {
|
|
Ok(guard) => break guard,
|
|
Err(err) if is_decommission_capacity_target_gate_busy(&err) => {
|
|
wait_attempt = wait_attempt.saturating_add(1);
|
|
}
|
|
Err(err) => return Err(err),
|
|
}
|
|
};
|
|
|
|
#[cfg(test)]
|
|
notify_decommission_target_gate_exact_reload(self.id);
|
|
let current = load_decommission_entry_exact_versions(set, entry, bucket, "target_gate_retry").await?;
|
|
ensure_decommission_capacity_target_fence(&target_guard, target_pool_index, "source identity revalidation")?;
|
|
let expected_identity = data_movement::source_cleanup_version_identity(expected_version);
|
|
let identity_current = current
|
|
.versions
|
|
.iter()
|
|
.any(|version| data_movement::source_cleanup_version_identity(version) == expected_identity);
|
|
if !identity_current {
|
|
return Ok(None);
|
|
}
|
|
let owner = capacity_owner.ok_or_else(|| Error::other("target-gate retry is missing its decommission capacity owner"))?;
|
|
let owner = owner.with_mutation_id(decommission_capacity_version_mutation_id(owner, bucket, expected_version));
|
|
install_decommission_capacity_target_permit(self.id, target_pool_index, owner, target_guard).map(Some)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
#[tracing::instrument(skip(
|
|
self,
|
|
set,
|
|
lifecycle_config,
|
|
object_lock_config,
|
|
replication_config,
|
|
source_changed_exhaustions
|
|
))]
|
|
async fn decommission_entry(
|
|
self: &Arc<Self>,
|
|
rx: CancellationToken,
|
|
idx: usize,
|
|
generation: OffsetDateTime,
|
|
entry: MetaCacheEntry,
|
|
bucket: String,
|
|
set: Arc<SetDisks>,
|
|
lifecycle_config: Option<BucketLifecycleConfiguration>,
|
|
object_lock_config: Option<ObjectLockConfiguration>,
|
|
replication_config: Option<(ReplicationConfiguration, OffsetDateTime)>,
|
|
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
|
source_changed_exhaustions: Arc<AtomicUsize>,
|
|
) -> Result<()> {
|
|
let mut counted_versions = HashSet::new();
|
|
|
|
for entry_attempt in 1..=DECOMMISSION_ENTRY_MAX_ATTEMPTS {
|
|
let attempt_result = {
|
|
let mut conflict_attempt = 0;
|
|
loop {
|
|
let result = self
|
|
.decommission_entry_attempt(
|
|
rx.clone(),
|
|
idx,
|
|
generation,
|
|
entry.clone(),
|
|
bucket.clone(),
|
|
Arc::clone(&set),
|
|
lifecycle_config.clone(),
|
|
object_lock_config.clone(),
|
|
replication_config.clone(),
|
|
expected_bucket_incarnation_id,
|
|
entry_attempt,
|
|
source_changed_exhaustions.as_ref(),
|
|
&mut counted_versions,
|
|
)
|
|
.await;
|
|
let retry = result
|
|
.as_ref()
|
|
.err()
|
|
.and_then(|err| decommission_capacity_retry_kind(err, conflict_attempt));
|
|
let retry_attempt = match retry {
|
|
Some(DecommissionCapacityRetryKind::IntentConflict) => {
|
|
conflict_attempt += 1;
|
|
conflict_attempt
|
|
}
|
|
None => break result,
|
|
};
|
|
let retry_delay = decommission_retry_backoff_delay(DECOMMISSION_SOURCE_CLEANUP_RETRY_DELAY, retry_attempt);
|
|
if wait_decommission_retry_backoff(&rx, retry_delay).await {
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
}
|
|
}
|
|
};
|
|
match attempt_result {
|
|
Ok(DecommissionEntryAttemptOutcome::Complete) => return Ok(()),
|
|
Ok(DecommissionEntryAttemptOutcome::SourceChanged) => {
|
|
let retry_delay = decommission_retry_backoff_delay(DECOMMISSION_SOURCE_CLEANUP_RETRY_DELAY, entry_attempt);
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "source_changed_retry",
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %entry.name,
|
|
attempt = entry_attempt,
|
|
max_attempts = DECOMMISSION_ENTRY_MAX_ATTEMPTS,
|
|
retry_delay_ms = retry_delay.as_millis(),
|
|
"Decommission source changed during cleanup preflight; retrying entry"
|
|
);
|
|
if wait_decommission_retry_backoff(&rx, retry_delay).await {
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
}
|
|
}
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
|
|
Err(Error::other(format!(
|
|
"decommission entry retry loop ended without a terminal result for {bucket}/{}",
|
|
entry.name
|
|
)))
|
|
}
|
|
|
|
#[allow(unused_assignments, clippy::too_many_arguments)]
|
|
async fn decommission_entry_attempt(
|
|
self: &Arc<Self>,
|
|
rx: CancellationToken,
|
|
idx: usize,
|
|
generation: OffsetDateTime,
|
|
entry: MetaCacheEntry,
|
|
bucket: String,
|
|
set: Arc<SetDisks>,
|
|
lifecycle_config: Option<BucketLifecycleConfiguration>,
|
|
object_lock_config: Option<ObjectLockConfiguration>,
|
|
replication_config: Option<(ReplicationConfiguration, OffsetDateTime)>,
|
|
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
|
entry_attempt: usize,
|
|
source_changed_exhaustions: &AtomicUsize,
|
|
counted_versions: &mut HashSet<(Option<uuid::Uuid>, bool)>,
|
|
) -> Result<DecommissionEntryAttemptOutcome> {
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "started",
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %entry.name,
|
|
attempt = entry_attempt,
|
|
max_attempts = DECOMMISSION_ENTRY_MAX_ATTEMPTS,
|
|
"Decommission entry started"
|
|
);
|
|
if entry.is_dir() {
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %entry.name,
|
|
state = "skipped_directory",
|
|
"Decommission entry skipped directory"
|
|
);
|
|
return Ok(DecommissionEntryAttemptOutcome::Complete);
|
|
}
|
|
let durable_ilm_record = if bucket == RUSTFS_META_BUCKET {
|
|
classify_durable_ilm_record(&entry.name)
|
|
.map_err(|err| with_decommission_entry_context("durable_ilm_namespace", &bucket, &entry.name, err))?
|
|
} else {
|
|
None
|
|
};
|
|
if self.decommission_cancel_requested(idx, &rx).await {
|
|
rx.cancel();
|
|
}
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
self.ensure_decommission_generation_current(idx, generation).await?;
|
|
self.ensure_decommission_runtime_capacity_available(idx, generation).await?;
|
|
let capacity_owner = self.decommission_capacity_owner_for_worker(idx, generation).await?;
|
|
let operation_gate = self.ctx.data_movement_operation_gate();
|
|
|
|
let bucket_incarnation_fence = match expected_bucket_incarnation_id {
|
|
Some(expected) => Some(self.acquire_bucket_incarnation_fence(&bucket, expected).await?),
|
|
None => None,
|
|
};
|
|
|
|
let mut fivs = load_decommission_entry_exact_versions(&set, &entry, &bucket, "file_info_versions").await?;
|
|
|
|
let pending_mutations = if let Some(owner) = capacity_owner {
|
|
self.pool_meta
|
|
.read()
|
|
.await
|
|
.pools
|
|
.get(owner.source_pool_index)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.filter(|reservation| reservation.admits_cleanup_owner(owner))
|
|
.map(|reservation| {
|
|
reservation
|
|
.targets
|
|
.iter()
|
|
.filter_map(|target| target.pending_mutation_id)
|
|
.collect::<HashSet<_>>()
|
|
})
|
|
.unwrap_or_default()
|
|
} else {
|
|
HashSet::new()
|
|
};
|
|
fivs.versions.sort_by_key(|version| {
|
|
let mutation_id = capacity_owner.map(|owner| decommission_capacity_version_mutation_id(owner, &bucket, version));
|
|
(
|
|
mutation_id.is_none_or(|mutation_id| !pending_mutations.contains(&mutation_id)),
|
|
version.mod_time.is_none(),
|
|
std::cmp::Reverse(version.mod_time),
|
|
)
|
|
});
|
|
|
|
let mut decommissioned: usize = 0;
|
|
let mut expired: usize = 0;
|
|
let mut free_version_disposition = DecommissionFreeVersionDisposition::default();
|
|
let mut cleanup_preflight_allowed_missing = Vec::new();
|
|
let mut entry_blocked = false;
|
|
|
|
for version in fivs.versions.iter() {
|
|
if self.decommission_cancel_requested(idx, &rx).await {
|
|
rx.cancel();
|
|
}
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
|
|
if version.tier_free_version() {
|
|
let version_id = version.version_id.map(|v| v.to_string());
|
|
let mut migration_error = None;
|
|
let mut migrated = false;
|
|
let mut consumed = false;
|
|
let mut capacity_failure = false;
|
|
let mut version_attempt = 1;
|
|
let mut target_busy_attempt: usize = 0;
|
|
let mut target_permit = None;
|
|
while version_attempt <= DECOMMISSION_VERSION_COPY_ATTEMPTS {
|
|
let result = self
|
|
.run_guarded_decommission_side_effect(&rx, &operation_gate, || async {
|
|
self.decommission_tiered_object(
|
|
bucket.as_str(),
|
|
&version.name,
|
|
version,
|
|
&decommission_capacity_owned_opts(
|
|
decommission_remote_tiered_opts(
|
|
version,
|
|
version_id.clone(),
|
|
idx,
|
|
expected_bucket_incarnation_id,
|
|
),
|
|
capacity_owner,
|
|
),
|
|
)
|
|
.await
|
|
})
|
|
.await;
|
|
drop(target_permit.take());
|
|
if let Some(target_pool_index) = result.as_ref().err().and_then(decommission_capacity_target_gate_busy_index)
|
|
{
|
|
target_busy_attempt = target_busy_attempt.saturating_add(1);
|
|
let Some(permit) = self
|
|
.wait_decommission_target_gate_retry(
|
|
&rx,
|
|
idx,
|
|
generation,
|
|
target_pool_index,
|
|
capacity_owner,
|
|
set.as_ref(),
|
|
&entry,
|
|
&bucket,
|
|
version,
|
|
target_busy_attempt,
|
|
)
|
|
.await?
|
|
else {
|
|
return Ok(DecommissionEntryAttemptOutcome::SourceChanged);
|
|
};
|
|
target_permit = Some(permit);
|
|
continue;
|
|
}
|
|
|
|
match classify_decommission_free_version_attempt(result) {
|
|
DecommissionFreeVersionAttempt::Migrated => {
|
|
migrated = true;
|
|
migration_error = None;
|
|
break;
|
|
}
|
|
DecommissionFreeVersionAttempt::Consumed => {
|
|
consumed = true;
|
|
migration_error = None;
|
|
break;
|
|
}
|
|
DecommissionFreeVersionAttempt::CapacityFailure(err) => {
|
|
capacity_failure = true;
|
|
migration_error = Some(err);
|
|
break;
|
|
}
|
|
DecommissionFreeVersionAttempt::Retry(err) => {
|
|
migration_error = Some(err);
|
|
version_attempt += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if counted_versions.insert((version.version_id, version.deleted)) {
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
ensure_decommission_generation(&pool_meta, idx, generation)?;
|
|
if let Err(err) = count_decommission_item(&mut pool_meta, idx, 0, !migrated && !consumed) {
|
|
return Err(with_decommission_entry_context(
|
|
"count_decommission_item",
|
|
bucket.as_str(),
|
|
entry.name.as_str(),
|
|
err,
|
|
));
|
|
}
|
|
}
|
|
|
|
if migrated || consumed {
|
|
decommissioned += 1;
|
|
cleanup_preflight_allowed_missing.push(data_movement::source_cleanup_version_identity(version));
|
|
}
|
|
if migrated {
|
|
free_version_disposition.record_migrated();
|
|
} else if consumed {
|
|
free_version_disposition.record_consumed();
|
|
} else {
|
|
free_version_disposition.record_retained();
|
|
}
|
|
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
version_id = ?version_id,
|
|
result = ?migration_error,
|
|
reason = if migrated {
|
|
DECOMMISSION_FREE_VERSION_MIGRATED_REASON
|
|
} else if consumed {
|
|
DECOMMISSION_FREE_VERSION_CONSUMED_REASON
|
|
} else {
|
|
DECOMMISSION_FREE_VERSION_RETAINED_REASON
|
|
},
|
|
state = if migrated {
|
|
"free_version_migrated"
|
|
} else if consumed {
|
|
"free_version_consumed"
|
|
} else {
|
|
"free_version_retained"
|
|
},
|
|
"Decommission free-version disposition recorded"
|
|
);
|
|
|
|
if capacity_failure {
|
|
return Err(with_decommission_entry_context(
|
|
"decommission_tier_free_version",
|
|
bucket.as_str(),
|
|
version.name.as_str(),
|
|
migration_error.expect("capacity failure must retain its error"),
|
|
));
|
|
}
|
|
|
|
if !migrated && !consumed {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if self
|
|
.run_guarded_decommission_side_effect(&rx, &operation_gate, || async {
|
|
should_skip_lifecycle_for_data_movement(
|
|
self.clone(),
|
|
&bucket,
|
|
version,
|
|
lifecycle_config.as_ref(),
|
|
object_lock_config.as_ref(),
|
|
true,
|
|
&LcEventSrc::Decom,
|
|
None,
|
|
)
|
|
.await
|
|
})
|
|
.await
|
|
.map_err(|err| with_decommission_entry_context("lifecycle_expiry", bucket.as_str(), version.name.as_str(), err))?
|
|
{
|
|
expired += 1;
|
|
cleanup_preflight_allowed_missing.push(data_movement::source_cleanup_version_identity(version));
|
|
continue;
|
|
}
|
|
|
|
let remaining_versions = decommission_remaining_version_count(&fivs.versions, expired);
|
|
if should_skip_decommission_delete_marker(version, remaining_versions, replication_config.is_some()) {
|
|
//
|
|
decommissioned += 1;
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
state = "skipped_delete_marker",
|
|
"Decommission delete marker skipped"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let version_id = version.version_id.map(|v| v.to_string());
|
|
|
|
let mut ignore = false;
|
|
let mut cleanup_ignored = false;
|
|
let mut failure = false;
|
|
let mut error = None;
|
|
if version.deleted {
|
|
let mut version_attempt = 1;
|
|
let mut target_busy_attempt: usize = 0;
|
|
let mut target_permit = None;
|
|
while version_attempt <= DECOMMISSION_VERSION_COPY_ATTEMPTS {
|
|
let result = self
|
|
.run_guarded_decommission_side_effect(&rx, &operation_gate, || async {
|
|
self.delete_object(
|
|
bucket.as_str(),
|
|
&version.name,
|
|
decommission_capacity_owned_opts(
|
|
decommission_delete_marker_opts(
|
|
version,
|
|
version_id.clone(),
|
|
idx,
|
|
expected_bucket_incarnation_id,
|
|
),
|
|
capacity_owner,
|
|
),
|
|
)
|
|
.await
|
|
})
|
|
.await;
|
|
drop(target_permit.take());
|
|
if let Some(target_pool_index) = result.as_ref().err().and_then(decommission_capacity_target_gate_busy_index)
|
|
{
|
|
target_busy_attempt = target_busy_attempt.saturating_add(1);
|
|
let Some(permit) = self
|
|
.wait_decommission_target_gate_retry(
|
|
&rx,
|
|
idx,
|
|
generation,
|
|
target_pool_index,
|
|
capacity_owner,
|
|
set.as_ref(),
|
|
&entry,
|
|
&bucket,
|
|
version,
|
|
target_busy_attempt,
|
|
)
|
|
.await?
|
|
else {
|
|
return Ok(DecommissionEntryAttemptOutcome::SourceChanged);
|
|
};
|
|
target_permit = Some(permit);
|
|
continue;
|
|
}
|
|
#[cfg(test)]
|
|
let result = decommission_test_wrap_result(
|
|
"delete_marker_copy",
|
|
bucket.as_str(),
|
|
version.name.as_str(),
|
|
version_attempt,
|
|
result,
|
|
);
|
|
|
|
match result {
|
|
Ok(_) => {
|
|
failure = false;
|
|
error = None;
|
|
break;
|
|
}
|
|
Err(err) if is_decommission_copy_cleanup_safe_error(&err) => {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "ignored_delete_marker_copy",
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
version_id = ?version_id,
|
|
error = ?err,
|
|
"Decommission delete marker copy ignored"
|
|
);
|
|
ignore = true;
|
|
cleanup_ignored = true;
|
|
break;
|
|
}
|
|
Err(err) if is_decommission_target_capacity_error(&err) => {
|
|
return Err(with_decommission_entry_context(
|
|
"delete_marker_copy",
|
|
bucket.as_str(),
|
|
version.name.as_str(),
|
|
err,
|
|
));
|
|
}
|
|
Err(err) => {
|
|
failure = true;
|
|
if version_attempt == DECOMMISSION_VERSION_COPY_ATTEMPTS {
|
|
error!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "delete_marker_copy_failed",
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
version_id = ?version_id,
|
|
attempts = DECOMMISSION_VERSION_COPY_ATTEMPTS,
|
|
error = ?err,
|
|
"Decommission delete marker copy failed"
|
|
);
|
|
error = Some(err);
|
|
break;
|
|
}
|
|
|
|
let retry_delay = decommission_retry_backoff_delay(DECOMMISSION_COPY_RETRY_DELAY, version_attempt);
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "delete_marker_copy_retry",
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
version_id = ?version_id,
|
|
attempt = version_attempt,
|
|
max_attempts = DECOMMISSION_VERSION_COPY_ATTEMPTS,
|
|
retry_delay_ms = retry_delay.as_millis(),
|
|
error = ?err,
|
|
"Decommission delete marker copy failed; retrying"
|
|
);
|
|
error = Some(err);
|
|
if wait_decommission_retry_backoff(&rx, retry_delay).await {
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
}
|
|
version_attempt += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ignore {
|
|
if should_count_decommission_version_complete(ignore, cleanup_ignored, failure) {
|
|
decommissioned += 1;
|
|
}
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
state = "ignored",
|
|
"Decommission entry ignored"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if counted_versions.insert((version.version_id, version.deleted)) {
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
ensure_decommission_generation(&pool_meta, idx, generation)?;
|
|
if let Err(err) = count_decommission_item(&mut pool_meta, idx, 0, failure) {
|
|
return Err(with_decommission_entry_context(
|
|
"count_decommission_item",
|
|
bucket.as_str(),
|
|
entry.name.as_str(),
|
|
err,
|
|
));
|
|
}
|
|
}
|
|
|
|
if !failure {
|
|
decommissioned += 1;
|
|
}
|
|
|
|
if !failure {
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "delete_marker_copied",
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
version_id = ?version_id,
|
|
result = ?error,
|
|
"Decommission delete marker copied"
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let mut version_attempt = 1;
|
|
let mut target_busy_attempt: usize = 0;
|
|
let mut target_permit = None;
|
|
while version_attempt <= DECOMMISSION_VERSION_COPY_ATTEMPTS {
|
|
if version.is_remote() {
|
|
let result = self
|
|
.run_guarded_decommission_side_effect(&rx, &operation_gate, || async {
|
|
self.decommission_tiered_object(
|
|
bucket.as_str(),
|
|
&version.name,
|
|
version,
|
|
&decommission_capacity_owned_opts(
|
|
decommission_remote_tiered_opts(
|
|
version,
|
|
version_id.clone(),
|
|
idx,
|
|
expected_bucket_incarnation_id,
|
|
),
|
|
capacity_owner,
|
|
),
|
|
)
|
|
.await
|
|
})
|
|
.await;
|
|
drop(target_permit.take());
|
|
if let Some(target_pool_index) = result.as_ref().err().and_then(decommission_capacity_target_gate_busy_index)
|
|
{
|
|
target_busy_attempt = target_busy_attempt.saturating_add(1);
|
|
let Some(permit) = self
|
|
.wait_decommission_target_gate_retry(
|
|
&rx,
|
|
idx,
|
|
generation,
|
|
target_pool_index,
|
|
capacity_owner,
|
|
set.as_ref(),
|
|
&entry,
|
|
&bucket,
|
|
version,
|
|
target_busy_attempt,
|
|
)
|
|
.await?
|
|
else {
|
|
return Ok(DecommissionEntryAttemptOutcome::SourceChanged);
|
|
};
|
|
target_permit = Some(permit);
|
|
continue;
|
|
}
|
|
#[cfg(test)]
|
|
let result = decommission_test_wrap_result(
|
|
"decommission_tiered_object",
|
|
bucket.as_str(),
|
|
version.name.as_str(),
|
|
version_attempt,
|
|
result,
|
|
);
|
|
|
|
match result {
|
|
Ok(_) => {
|
|
failure = false;
|
|
error = None;
|
|
}
|
|
Err(err) if is_decommission_copy_cleanup_safe_error(&err) => {
|
|
ignore = true;
|
|
cleanup_ignored = true;
|
|
}
|
|
Err(err) if is_decommission_target_capacity_error(&err) => {
|
|
return Err(with_decommission_entry_context(
|
|
"decommission_tiered_object",
|
|
bucket.as_str(),
|
|
version.name.as_str(),
|
|
err,
|
|
));
|
|
}
|
|
Err(err) => {
|
|
failure = true;
|
|
if version_attempt == DECOMMISSION_VERSION_COPY_ATTEMPTS {
|
|
error!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "tiered_copy_failed",
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
version_id = ?version_id,
|
|
attempts = DECOMMISSION_VERSION_COPY_ATTEMPTS,
|
|
error = ?err,
|
|
"Decommission tiered version copy failed"
|
|
);
|
|
error = Some(err);
|
|
} else {
|
|
let retry_delay =
|
|
decommission_retry_backoff_delay(DECOMMISSION_COPY_RETRY_DELAY, version_attempt);
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "tiered_copy_retry",
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
version_id = ?version_id,
|
|
attempt = version_attempt,
|
|
max_attempts = DECOMMISSION_VERSION_COPY_ATTEMPTS,
|
|
retry_delay_ms = retry_delay.as_millis(),
|
|
error = ?err,
|
|
"Decommission tiered version copy failed; retrying"
|
|
);
|
|
error = Some(err);
|
|
if wait_decommission_retry_backoff(&rx, retry_delay).await {
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
}
|
|
version_attempt += 1;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
let bucket = bucket.clone();
|
|
|
|
let rd = match set
|
|
.get_object_reader(
|
|
bucket.as_str(),
|
|
&encode_dir_object(&version.name),
|
|
None,
|
|
HeaderMap::new(),
|
|
&decommission_object_migration_read_opts(version_id.clone()),
|
|
)
|
|
.await
|
|
{
|
|
Ok(rd) => rd,
|
|
Err(err) => {
|
|
if is_err_object_not_found(&err) || is_err_version_not_found(&err) {
|
|
ignore = true;
|
|
cleanup_ignored = true;
|
|
break;
|
|
}
|
|
|
|
if !ignore {
|
|
//
|
|
if bucket == RUSTFS_META_BUCKET && version.name.contains(DATA_USAGE_CACHE_NAME) {
|
|
ignore = true;
|
|
error!("decommission_pool: ignore data usage cache {}", &version.name);
|
|
break;
|
|
}
|
|
}
|
|
|
|
failure = true;
|
|
if version_attempt == DECOMMISSION_VERSION_COPY_ATTEMPTS {
|
|
error!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "object_read_failed",
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
version_id = ?version_id,
|
|
attempts = DECOMMISSION_VERSION_COPY_ATTEMPTS,
|
|
error = ?err,
|
|
"Decommission source object read failed"
|
|
);
|
|
error = Some(err);
|
|
break;
|
|
}
|
|
|
|
let retry_delay = decommission_retry_backoff_delay(DECOMMISSION_COPY_RETRY_DELAY, version_attempt);
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "object_read_retry",
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
version_id = ?version_id,
|
|
attempt = version_attempt,
|
|
max_attempts = DECOMMISSION_VERSION_COPY_ATTEMPTS,
|
|
retry_delay_ms = retry_delay.as_millis(),
|
|
error = ?err,
|
|
"Decommission source object read failed; retrying"
|
|
);
|
|
error = Some(err);
|
|
if wait_decommission_retry_backoff(&rx, retry_delay).await {
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
}
|
|
version_attempt += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let bucket_name = bucket.clone();
|
|
let object_name = rd.object_info.name.clone();
|
|
|
|
self.track_decommission_entry_progress_stage(
|
|
idx,
|
|
generation,
|
|
bucket_name.as_str(),
|
|
object_name.as_str(),
|
|
DECOMMISSION_STAGE_MIGRATE_OBJECT,
|
|
)
|
|
.await?;
|
|
|
|
let migrate_result = self
|
|
.run_guarded_decommission_side_effect(&rx, &operation_gate, || async {
|
|
self.clone()
|
|
.decommission_object(idx, bucket, rd, expected_bucket_incarnation_id, capacity_owner)
|
|
.await
|
|
})
|
|
.await;
|
|
drop(target_permit.take());
|
|
if let Some(target_pool_index) = migrate_result
|
|
.as_ref()
|
|
.err()
|
|
.and_then(decommission_capacity_target_gate_busy_index)
|
|
{
|
|
target_busy_attempt = target_busy_attempt.saturating_add(1);
|
|
let Some(permit) = self
|
|
.wait_decommission_target_gate_retry(
|
|
&rx,
|
|
idx,
|
|
generation,
|
|
target_pool_index,
|
|
capacity_owner,
|
|
set.as_ref(),
|
|
&entry,
|
|
&bucket_name,
|
|
version,
|
|
target_busy_attempt,
|
|
)
|
|
.await?
|
|
else {
|
|
return Ok(DecommissionEntryAttemptOutcome::SourceChanged);
|
|
};
|
|
target_permit = Some(permit);
|
|
continue;
|
|
}
|
|
#[cfg(test)]
|
|
let migrate_result = decommission_test_wrap_result(
|
|
DECOMMISSION_STAGE_MIGRATE_OBJECT,
|
|
bucket_name.as_str(),
|
|
object_name.as_str(),
|
|
version_attempt,
|
|
migrate_result,
|
|
);
|
|
|
|
if let Err(err) = migrate_result {
|
|
if is_decommission_copy_cleanup_safe_error(&err) {
|
|
ignore = true;
|
|
cleanup_ignored = true;
|
|
break;
|
|
}
|
|
|
|
if is_decommission_target_capacity_error(&err) {
|
|
return Err(with_decommission_entry_context(
|
|
DECOMMISSION_STAGE_MIGRATE_OBJECT,
|
|
bucket_name.as_str(),
|
|
object_name.as_str(),
|
|
err,
|
|
));
|
|
}
|
|
|
|
failure = true;
|
|
if version_attempt == DECOMMISSION_VERSION_COPY_ATTEMPTS {
|
|
error!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "object_migration_failed",
|
|
pool_index = idx,
|
|
bucket = %bucket_name,
|
|
object = %object_name,
|
|
version = %version.name,
|
|
attempt = version_attempt,
|
|
max_attempts = DECOMMISSION_VERSION_COPY_ATTEMPTS,
|
|
error = ?err,
|
|
"Decommission object migration failed"
|
|
);
|
|
error = Some(err);
|
|
break;
|
|
}
|
|
|
|
let retry_delay = decommission_retry_backoff_delay(DECOMMISSION_COPY_RETRY_DELAY, version_attempt);
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "object_migration_retry",
|
|
pool_index = idx,
|
|
bucket = %bucket_name,
|
|
object = %object_name,
|
|
version = %version.name,
|
|
attempt = version_attempt,
|
|
max_attempts = DECOMMISSION_VERSION_COPY_ATTEMPTS,
|
|
retry_delay_ms = retry_delay.as_millis(),
|
|
error = ?err,
|
|
"Decommission object migration failed; retrying"
|
|
);
|
|
error = Some(err);
|
|
if wait_decommission_retry_backoff(&rx, retry_delay).await {
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
}
|
|
version_attempt += 1;
|
|
continue;
|
|
}
|
|
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bucket_name,
|
|
object = %object_name,
|
|
version = %version.name,
|
|
state = "object_migrated",
|
|
"Decommission object migrated"
|
|
);
|
|
|
|
failure = false;
|
|
break;
|
|
}
|
|
|
|
if ignore {
|
|
if should_count_decommission_version_complete(ignore, cleanup_ignored, failure) {
|
|
decommissioned += 1;
|
|
}
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %version.name,
|
|
state = "ignored",
|
|
"Decommission entry ignored"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if counted_versions.insert((version.version_id, version.deleted)) {
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
ensure_decommission_generation(&pool_meta, idx, generation)?;
|
|
if let Err(err) = count_decommission_item(&mut pool_meta, idx, decommission_item_size(version.size), failure) {
|
|
return Err(with_decommission_entry_context(
|
|
"count_decommission_item",
|
|
bucket.as_str(),
|
|
entry.name.as_str(),
|
|
err,
|
|
));
|
|
}
|
|
}
|
|
|
|
if failure {
|
|
break;
|
|
}
|
|
|
|
if should_count_decommission_version_complete(ignore, cleanup_ignored, failure) {
|
|
decommissioned += 1;
|
|
}
|
|
}
|
|
|
|
if free_version_disposition.total() > 0 {
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %entry.name,
|
|
free_versions_migrated = free_version_disposition.migrated,
|
|
free_versions_consumed = free_version_disposition.consumed,
|
|
free_versions_retained = free_version_disposition.retained,
|
|
free_versions_total = free_version_disposition.total(),
|
|
reason = DECOMMISSION_FREE_VERSION_DISPOSITION_REASON,
|
|
state = "free_version_disposition",
|
|
"Decommission free-version disposition summary"
|
|
);
|
|
}
|
|
|
|
if should_cleanup_decommission_source_entry(decommissioned, fivs.versions.len(), expired) && durable_ilm_record.is_none()
|
|
{
|
|
if bucket_incarnation_fence.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
|
return Err(Error::other("decommission bucket incarnation fence was lost before source cleanup"));
|
|
}
|
|
decommission_cancel_signal_result(rx.is_cancelled())?;
|
|
self.ensure_decommission_generation_current(idx, generation).await?;
|
|
|
|
self.track_decommission_entry_progress_stage(
|
|
idx,
|
|
generation,
|
|
bucket.as_str(),
|
|
entry.name.as_str(),
|
|
DECOMMISSION_STAGE_CLEANUP_PREFLIGHT,
|
|
)
|
|
.await?;
|
|
|
|
self.track_decommission_entry_progress_stage(
|
|
idx,
|
|
generation,
|
|
bucket.as_str(),
|
|
entry.name.as_str(),
|
|
DECOMMISSION_STAGE_SOURCE_CLEANUP,
|
|
)
|
|
.await?;
|
|
|
|
#[cfg(test)]
|
|
run_decommission_cleanup_mutation_hook(bucket.as_str(), entry.name.as_str(), entry_attempt).await;
|
|
|
|
let source_cleanup_mutation_fence = self
|
|
.acquire_decommission_source_cleanup_fence(bucket.as_str(), entry.name.as_str(), set.as_ref())
|
|
.await?;
|
|
let cleanup_result = self
|
|
.run_guarded_decommission_side_effect(&rx, &operation_gate, || async {
|
|
data_movement::cleanup_source_entry_if_unchanged(
|
|
set.clone(),
|
|
bucket.as_str(),
|
|
entry.name.as_str(),
|
|
&fivs,
|
|
&cleanup_preflight_allowed_missing,
|
|
data_movement::SourceCleanupBucketFence {
|
|
expected_incarnation_id: expected_bucket_incarnation_id,
|
|
lifecycle_guard: bucket_incarnation_fence
|
|
.as_ref()
|
|
.and_then(|guard| guard.namespace_lock_guard()),
|
|
namespace_lock_lost_signal: None,
|
|
object_mutation_fence: Some(&source_cleanup_mutation_fence),
|
|
},
|
|
"decommission",
|
|
)
|
|
.await
|
|
})
|
|
.await;
|
|
match cleanup_result {
|
|
Ok(_) => {}
|
|
Err(data_movement::SourceCleanupError::Storage(err)) => {
|
|
resolve_decommission_entry_cleanup_delete_result(
|
|
Err::<(), Error>(err),
|
|
bucket.as_str(),
|
|
entry.name.as_str(),
|
|
)?;
|
|
}
|
|
Err(data_movement::SourceCleanupError::SourceChanged) if entry_attempt < DECOMMISSION_ENTRY_MAX_ATTEMPTS => {
|
|
return Ok(DecommissionEntryAttemptOutcome::SourceChanged);
|
|
}
|
|
Err(data_movement::SourceCleanupError::SourceChanged) => {
|
|
let exhausted_entries = source_changed_exhaustions.fetch_add(1, Ordering::Relaxed) + 1;
|
|
if should_fail_decommission_pool_after_exhausted_source_changed(exhausted_entries) {
|
|
return Err(Error::other(format!(
|
|
"decommission source cleanup retries exhausted for {}/{} on all {} attempts; exhausted entries exceed pool limit {}",
|
|
bucket, entry.name, DECOMMISSION_ENTRY_MAX_ATTEMPTS, DECOMMISSION_SOURCE_CHANGED_EXHAUSTION_LIMIT
|
|
)));
|
|
}
|
|
|
|
{
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
ensure_decommission_generation(&pool_meta, idx, generation)?;
|
|
count_decommission_item(&mut pool_meta, idx, 0, true).map_err(|err| {
|
|
with_decommission_entry_context(
|
|
"count_source_changed_exhaustion",
|
|
bucket.as_str(),
|
|
entry.name.as_str(),
|
|
err,
|
|
)
|
|
})?;
|
|
}
|
|
|
|
error!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "source_cleanup_exhausted",
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %entry.name,
|
|
attempts = DECOMMISSION_ENTRY_MAX_ATTEMPTS,
|
|
exhausted_entries,
|
|
exhaustion_limit = DECOMMISSION_SOURCE_CHANGED_EXHAUSTION_LIMIT,
|
|
"Decommission source cleanup retries exhausted; source retained and entry marked failed"
|
|
);
|
|
entry_blocked = true;
|
|
}
|
|
}
|
|
} else if durable_ilm_record.is_some() {
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %entry.name,
|
|
state = "retained_for_final_verification",
|
|
"Decommission durable ILM source retained for final verification"
|
|
);
|
|
} else if decommissioned != fivs.versions.len() || expired > 0 {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %entry.name,
|
|
decommissioned,
|
|
total_versions = fivs.versions.len(),
|
|
expired,
|
|
state = "source_retained",
|
|
"Decommission source object retained"
|
|
);
|
|
}
|
|
|
|
let should_save_progress = {
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
ensure_decommission_generation(&pool_meta, idx, generation)?;
|
|
|
|
if let Err(err) = track_decommission_current_object(&mut pool_meta, idx, bucket.as_str(), entry.name.as_str()) {
|
|
return Err(with_decommission_entry_context(
|
|
"track_decommission_current_object",
|
|
bucket.as_str(),
|
|
entry.name.as_str(),
|
|
err,
|
|
));
|
|
}
|
|
|
|
match resolve_decommission_update_after_result(pool_meta.update_after(idx, DECOMMISSION_PROGRESS_SAVE_INTERVAL)) {
|
|
Ok(ok) => ok,
|
|
Err(err) => {
|
|
return Err(with_decommission_entry_context("update_after", bucket.as_str(), entry.name.as_str(), err));
|
|
}
|
|
}
|
|
};
|
|
|
|
self.track_decommission_entry_progress_stage(
|
|
idx,
|
|
generation,
|
|
bucket.as_str(),
|
|
entry.name.as_str(),
|
|
DECOMMISSION_STAGE_ENTRY_FINISHED,
|
|
)
|
|
.await?;
|
|
|
|
if should_save_progress {
|
|
match self.save_decommission_progress_checkpoint(idx, generation).await {
|
|
Ok(true) => {
|
|
if let Some(notification_sys) = runtime_sources::notification_sys()
|
|
&& let Err(err) = resolve_decommission_entry_reload_result(
|
|
notification_sys.reload_pool_meta().await,
|
|
bucket.as_str(),
|
|
entry.name.as_str(),
|
|
)
|
|
{
|
|
warn!("{err}");
|
|
}
|
|
}
|
|
Ok(false) => {}
|
|
Err(err) => {
|
|
if let Some(err) = resolve_decommission_progress_save_result(Err(err)) {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %entry.name,
|
|
state = "progress_save_failed",
|
|
error = %err,
|
|
"Decommission progress save failed; continuing and will retry at the next checkpoint"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = if entry_blocked { "blocked" } else { "completed" },
|
|
pool_index = idx,
|
|
bucket = %bucket,
|
|
object = %entry.name,
|
|
"Decommission entry finished"
|
|
);
|
|
Ok(DecommissionEntryAttemptOutcome::Complete)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn decommission_entry_for_test(
|
|
self: &Arc<Self>,
|
|
idx: usize,
|
|
entry: MetaCacheEntry,
|
|
bucket: String,
|
|
set: Arc<SetDisks>,
|
|
) -> Result<()> {
|
|
self.decommission_entry_with_retry_state_for_test(
|
|
CancellationToken::new(),
|
|
idx,
|
|
entry,
|
|
bucket,
|
|
set,
|
|
None,
|
|
Arc::new(AtomicUsize::new(0)),
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn decommission_entry_with_retry_state_for_test(
|
|
self: &Arc<Self>,
|
|
rx: CancellationToken,
|
|
idx: usize,
|
|
entry: MetaCacheEntry,
|
|
bucket: String,
|
|
set: Arc<SetDisks>,
|
|
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
|
source_changed_exhaustions: Arc<AtomicUsize>,
|
|
) -> Result<()> {
|
|
{
|
|
let mut cancelers = self.decommission_cancelers.write().await;
|
|
if cancelers.get(idx).and_then(Option::as_ref).is_none()
|
|
&& let Some(slot) = cancelers.get_mut(idx)
|
|
{
|
|
*slot = Some(DecommissionCanceler::new(CancellationToken::new()));
|
|
}
|
|
}
|
|
let needs_capacity_reservation = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
pool_meta
|
|
.pools
|
|
.get(idx)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.is_some_and(|reservation| !reservation.active())
|
|
};
|
|
if needs_capacity_reservation {
|
|
let capacity_infos = self.get_decommission_all_pool_capacity_infos().await?;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
let version = pool_meta.version;
|
|
pool_meta.version = POOL_META_VERSION;
|
|
recover_decommission_capacity_reservations(
|
|
&mut pool_meta,
|
|
&capacity_infos,
|
|
OffsetDateTime::now_utc(),
|
|
crate::services::notification_sys::acquire_decommission_target_fence_fleet_proof().is_some(),
|
|
)?;
|
|
pool_meta.version = version;
|
|
}
|
|
let generation = self.active_decommission_generation(idx).await?;
|
|
self.decommission_entry(
|
|
rx,
|
|
idx,
|
|
generation,
|
|
entry,
|
|
bucket,
|
|
set,
|
|
None,
|
|
None,
|
|
None,
|
|
expected_bucket_incarnation_id,
|
|
source_changed_exhaustions,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
pub(crate) async fn decommission_entry_for_test_with_bucket_incarnation(
|
|
self: &Arc<Self>,
|
|
idx: usize,
|
|
entry: MetaCacheEntry,
|
|
bucket: String,
|
|
set: Arc<SetDisks>,
|
|
) -> Result<()> {
|
|
let expected_bucket_incarnation_id = if is_meta_bucketname(&bucket) {
|
|
None
|
|
} else {
|
|
Some(self.bucket_incarnation_id_from_disk(&bucket).await?)
|
|
};
|
|
let generation = self.active_decommission_generation(idx).await?;
|
|
self.decommission_entry(
|
|
CancellationToken::new(),
|
|
idx,
|
|
generation,
|
|
entry,
|
|
bucket,
|
|
set,
|
|
None,
|
|
None,
|
|
None,
|
|
expected_bucket_incarnation_id,
|
|
Arc::new(AtomicUsize::new(0)),
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tracing::instrument(skip(self, rx))]
|
|
async fn decommission_pool(
|
|
self: &Arc<Self>,
|
|
rx: CancellationToken,
|
|
idx: usize,
|
|
pool: Arc<Sets>,
|
|
bi: DecomBucketInfo,
|
|
entry_budget: Arc<Semaphore>,
|
|
source_changed_exhaustions: Arc<AtomicUsize>,
|
|
) -> Result<()> {
|
|
let entry_error = Arc::new(tokio::sync::Mutex::new(None::<Error>));
|
|
let generation = self.active_decommission_generation(idx).await?;
|
|
let mut listing_workers = Vec::with_capacity(pool.disk_set.len());
|
|
|
|
let mut lifecycle_config = None;
|
|
let mut object_lock_config = None;
|
|
let mut replication_config = None;
|
|
let expected_bucket_incarnation_id = if bi.name == RUSTFS_META_BUCKET {
|
|
None
|
|
} else {
|
|
Some(self.bucket_incarnation_id_from_disk(&bi.name).await?)
|
|
};
|
|
|
|
if bi.name != RUSTFS_META_BUCKET {
|
|
let _ = resolve_decommission_optional_bucket_config_result(
|
|
&bi.name,
|
|
"versioning",
|
|
BucketVersioningSys::get_in(&self.ctx, &bi.name).await,
|
|
)?;
|
|
let expiry_configs = get_expiry_configs(self, &bi.name).await?;
|
|
lifecycle_config = expiry_configs.lifecycle.map(|config| (*config).clone());
|
|
object_lock_config = expiry_configs.object_lock.map(|config| (*config).clone());
|
|
replication_config = resolve_decommission_optional_bucket_config_result(
|
|
&bi.name,
|
|
"replication",
|
|
metadata_sys::get_replication_config_in(&self.ctx, &bi.name).await,
|
|
)?;
|
|
}
|
|
|
|
for (set_idx, set) in pool.disk_set.iter().enumerate() {
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
set_index = set_idx,
|
|
bucket = %bi.name,
|
|
state = "listing_worker_started",
|
|
"Decommission listing worker started"
|
|
);
|
|
|
|
let set = set.clone();
|
|
let store = Arc::clone(self);
|
|
let rx_clone = rx.clone();
|
|
let bi_clone = bi.clone();
|
|
let lifecycle_config = lifecycle_config.clone();
|
|
let object_lock_config = object_lock_config.clone();
|
|
let replication_config = replication_config.clone();
|
|
let entry_budget = entry_budget.clone();
|
|
let source_changed_exhaustions = Arc::clone(&source_changed_exhaustions);
|
|
let entry_error = entry_error.clone();
|
|
let worker = tokio::spawn(async move {
|
|
store
|
|
.decommission_set(
|
|
rx_clone,
|
|
idx,
|
|
set_idx,
|
|
generation,
|
|
set,
|
|
bi_clone,
|
|
lifecycle_config,
|
|
object_lock_config,
|
|
replication_config,
|
|
expected_bucket_incarnation_id,
|
|
source_changed_exhaustions,
|
|
entry_budget,
|
|
entry_error,
|
|
)
|
|
.await
|
|
});
|
|
listing_workers.push((set_idx, worker));
|
|
}
|
|
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bi.name,
|
|
state = "waiting_for_workers",
|
|
"Decommission waiting for workers"
|
|
);
|
|
|
|
let mut listing_worker_error = None;
|
|
for (set_id, worker) in listing_workers {
|
|
if let Err(err) = resolve_decommission_listing_worker_result(set_id, worker.await) {
|
|
rx.cancel();
|
|
if listing_worker_error.is_none() {
|
|
listing_worker_error = Some(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(err) = listing_worker_error {
|
|
return Err(err);
|
|
}
|
|
|
|
if let Some(err) = entry_error.lock().await.clone() {
|
|
return Err(err);
|
|
}
|
|
|
|
if let Err(err) = decommission_cancel_signal_result(rx.is_cancelled()) {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bi.name,
|
|
state = "cancelled_after_wait",
|
|
error = %err,
|
|
"Decommission bucket cancelled after wait"
|
|
);
|
|
return Err(err);
|
|
}
|
|
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bi.name,
|
|
state = "completed",
|
|
"Decommission bucket completed"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
pub(crate) async fn decommission_pool_for_test(
|
|
self: &Arc<Self>,
|
|
rx: CancellationToken,
|
|
idx: usize,
|
|
pool: Arc<Sets>,
|
|
bucket: DecomBucketInfo,
|
|
) -> Result<()> {
|
|
self.decommission_pool(
|
|
rx,
|
|
idx,
|
|
pool,
|
|
bucket,
|
|
Arc::new(Semaphore::new(decommission_entry_concurrency_limit())),
|
|
Arc::new(AtomicUsize::new(0)),
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tracing::instrument(skip(self, canceler))]
|
|
pub async fn do_decommission_in_routine(
|
|
self: &Arc<Self>,
|
|
canceler: DecommissionCanceler,
|
|
idx: usize,
|
|
entry_budget: Arc<Semaphore>,
|
|
) -> Result<()> {
|
|
let rx = canceler.token().clone();
|
|
self.run_decommission_in_routine(rx, idx, &canceler, entry_budget).await
|
|
}
|
|
|
|
async fn run_decommission_in_routine(
|
|
self: &Arc<Self>,
|
|
rx: CancellationToken,
|
|
idx: usize,
|
|
canceler: &DecommissionCanceler,
|
|
entry_budget: Arc<Semaphore>,
|
|
) -> Result<()> {
|
|
self.ensure_pool_meta_side_effects_safe("decommission cannot run while pool metadata requires recovery")
|
|
.await?;
|
|
let generation = match self.promote_queued_decommission(idx, canceler).await {
|
|
Ok(generation) => generation,
|
|
Err(Error::OperationCanceled) => return Ok(()),
|
|
Err(err) if is_decommission_capacity_blocked_error(&err) || is_decommission_target_capacity_error(&err) => {
|
|
if let Err(pause_err) = self.pause_decommission_for_capacity(idx, &err).await {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"failed to persist paused state for pool {idx}: {pause_err}"
|
|
)));
|
|
}
|
|
return Ok(());
|
|
}
|
|
Err(err) => {
|
|
resolve_decommission_terminal_mark_after_error_result(
|
|
self.decommission_failed_for_operation(idx, canceler).await,
|
|
idx,
|
|
&err,
|
|
)?;
|
|
return Err(err);
|
|
}
|
|
};
|
|
if rx.is_cancelled() {
|
|
let already_canceled = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
should_skip_canceled_decommission_routine(true, pool_meta.pools.get(idx))
|
|
};
|
|
if already_canceled {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "canceled_preserved",
|
|
"Decommission routine skipped because pool is already canceled"
|
|
);
|
|
return Ok(());
|
|
}
|
|
if let Err(err) = self.decommission_cancel_for_operation(idx, canceler).await {
|
|
resolve_decommission_terminal_mark_after_error_result(
|
|
self.decommission_failed_for_operation(idx, canceler).await,
|
|
idx,
|
|
&err,
|
|
)?;
|
|
return Err(err);
|
|
}
|
|
return Ok(());
|
|
}
|
|
let result = self
|
|
.decommission_in_background(rx.clone(), idx, generation, entry_budget)
|
|
.await;
|
|
|
|
if let Err(err) = &result
|
|
&& (is_decommission_capacity_blocked_error(err) || is_decommission_target_capacity_error(err))
|
|
{
|
|
if let Err(pause_err) = self.pause_decommission_for_capacity(idx, err).await {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"failed to persist paused state for pool {idx}: {pause_err}"
|
|
)));
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
let (final_state, canceled, cmd_line) = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
let Some(pool) = pool_meta.pools.get(idx) else {
|
|
error!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "pool_metadata_missing",
|
|
"Decommission pool metadata missing"
|
|
);
|
|
return Err(Error::other(format!(
|
|
"failed to resolve decommission final state: pool metadata missing for idx {idx}"
|
|
)));
|
|
};
|
|
|
|
let (final_state, canceled) = if let Some(info) = &pool.decommission {
|
|
(
|
|
determine_decommission_final_state(info.items_decommission_failed, info.canceled),
|
|
info.canceled,
|
|
)
|
|
} else {
|
|
(DecommissionFinalState::Failed, false)
|
|
};
|
|
let cmd_line = pool.cmd_line.clone();
|
|
(final_state, canceled, cmd_line)
|
|
};
|
|
|
|
if let Err(err) = result {
|
|
error!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "background_failed",
|
|
error = ?err,
|
|
"Decommission background routine failed"
|
|
);
|
|
|
|
if should_preserve_decommission_canceled_state(canceled, rx.is_cancelled()) {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
cmd_line = %cmd_line,
|
|
state = "cancelled_preserved",
|
|
"Decommission cancelled; preserving canceled state"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
resolve_decommission_terminal_mark_after_error_result(
|
|
self.decommission_failed_for_operation(idx, canceler).await,
|
|
idx,
|
|
&err,
|
|
)?;
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "marked_failed",
|
|
"Decommission marked failed"
|
|
);
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "background_complete",
|
|
"Decommission background routine completed"
|
|
);
|
|
|
|
if should_preserve_decommission_canceled_state(canceled, rx.is_cancelled()) {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
cmd_line = %cmd_line,
|
|
state = "terminal_state_preserved",
|
|
"Decommission terminal state preserved after cancellation"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
match final_state {
|
|
DecommissionFinalState::Complete => {
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
cmd_line = %cmd_line,
|
|
state = "verifying_completion",
|
|
"Decommission completion verification started"
|
|
);
|
|
let verified_unresolved_entries = match self.check_after_decommission(idx, &rx, generation).await {
|
|
Ok(verified_entries) => verified_entries,
|
|
Err(err) => {
|
|
if is_err_operation_canceled(&err) {
|
|
return Err(err);
|
|
}
|
|
resolve_decommission_terminal_mark_result(
|
|
self.decommission_failed_for_operation(idx, canceler).await,
|
|
"failed",
|
|
&cmd_line,
|
|
)?;
|
|
return Err(Error::other(format!(
|
|
"failed to finalize decommission for pool {cmd_line}: post-check failed: {err}"
|
|
)));
|
|
}
|
|
};
|
|
info!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
cmd_line = %cmd_line,
|
|
state = "marking_completed",
|
|
"Decommission marking completed state"
|
|
);
|
|
if let Err(err) = self
|
|
.complete_decommission_for_operation(idx, canceler, generation, verified_unresolved_entries)
|
|
.await
|
|
{
|
|
resolve_decommission_terminal_mark_result(
|
|
self.decommission_failed_for_operation(idx, canceler).await,
|
|
"failed",
|
|
&cmd_line,
|
|
)?;
|
|
return Err(Error::other(format!("failed to finalize decommission for pool {cmd_line}: {err}")));
|
|
}
|
|
}
|
|
DecommissionFinalState::Failed => {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
cmd_line = %cmd_line,
|
|
state = "marking_failed",
|
|
"Decommission marking failed state"
|
|
);
|
|
resolve_decommission_terminal_mark_result(
|
|
self.decommission_failed_for_operation(idx, canceler).await,
|
|
"failed",
|
|
&cmd_line,
|
|
)?;
|
|
}
|
|
}
|
|
|
|
info!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
cmd_line = %cmd_line,
|
|
state = "completed",
|
|
"Decommission completed"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn decommission_failed(&self, idx: usize) -> Result<()> {
|
|
self.decommission_failed_with_owner(idx, None).await
|
|
}
|
|
|
|
async fn decommission_failed_for_operation(&self, idx: usize, owner: &DecommissionCanceler) -> Result<()> {
|
|
self.decommission_failed_with_owner(idx, Some(owner)).await
|
|
}
|
|
|
|
async fn decommission_failed_with_owner(&self, idx: usize, owner: Option<&DecommissionCanceler>) -> Result<()> {
|
|
self.decommission_failed_with_owner_and_save(idx, owner, self.save_current_pool_meta(&[idx]))
|
|
.await
|
|
}
|
|
|
|
async fn decommission_failed_with_owner_and_save<SaveFuture>(
|
|
&self,
|
|
idx: usize,
|
|
owner: Option<&DecommissionCanceler>,
|
|
save_pool_meta: SaveFuture,
|
|
) -> Result<()>
|
|
where
|
|
SaveFuture: Future<Output = Result<()>>,
|
|
{
|
|
ensure_decommission_terminal_operation_supported(self.single_pool(), "mark decommission failed")?;
|
|
let _start_guard = self.start_gate.lock().await;
|
|
let movement_gate = self.ctx.data_movement_operation_gate();
|
|
let _movement_guard = movement_gate.write().await;
|
|
|
|
// Lock order: movement gate, rebalance_meta, decommission_cancelers,
|
|
// then pool_meta. Holding both state locks makes owner validation and
|
|
// the terminal transition one atomic operation.
|
|
let rebalance_meta = self.rebalance_meta.read().await.clone();
|
|
let terminal_at = OffsetDateTime::now_utc();
|
|
let (should_reload_pool_meta, previous_pool_meta, terminal_canceler) = {
|
|
let cancelers = self.decommission_cancelers.read().await;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
let previous_pool_meta = pool_meta.clone();
|
|
let Some(changed) =
|
|
update_decommission_for_operation(cancelers.as_slice(), &mut pool_meta, idx, owner, |pool_meta| {
|
|
pool_meta.decommission_failed_at(idx, terminal_at, rebalance_meta.as_ref())
|
|
})
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let terminal_canceler = if let Some(owner) = owner {
|
|
Some(owner.clone())
|
|
} else {
|
|
cancelers.get(idx).and_then(Option::as_ref).cloned()
|
|
};
|
|
(changed, changed.then_some(previous_pool_meta), terminal_canceler)
|
|
};
|
|
|
|
if should_reload_pool_meta && let Err(err) = save_pool_meta.await {
|
|
if let Some(previous_pool_meta) = previous_pool_meta {
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
rollback_decommission_pool_meta(&mut pool_meta, &previous_pool_meta, &[idx]);
|
|
}
|
|
return Err(err);
|
|
}
|
|
if should_reload_pool_meta {
|
|
{
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
pool_meta.mark_decommission_progress_saved();
|
|
}
|
|
}
|
|
if let Some(canceler) = terminal_canceler.as_ref() {
|
|
self.release_decommission_canceler_slot(idx, canceler).await;
|
|
}
|
|
|
|
if should_reload_pool_meta {
|
|
self.ctx.advance_data_movement_operation_epoch();
|
|
}
|
|
drop(_movement_guard);
|
|
|
|
if should_reload_pool_meta && let Some(notification_sys) = runtime_sources::notification_sys() {
|
|
let stage = format!("decommission_failed for pool {idx}");
|
|
if let Some(err) = observe_decommission_terminal_reload_result(
|
|
resolve_decommission_pool_meta_reload_result(notification_sys.reload_pool_meta().await, stage.as_str()),
|
|
stage.as_str(),
|
|
) {
|
|
if let Err(record_err) = self
|
|
.record_decommission_terminal_reload_failure(idx, stage.as_str(), err.clone())
|
|
.await
|
|
{
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "terminal_reload_record_failed",
|
|
error = %record_err,
|
|
original_error = %err,
|
|
"Decommission terminal reload failure record failed"
|
|
);
|
|
}
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "terminal_reload_failed",
|
|
error = %err,
|
|
"Decommission terminal state saved but pool meta reload failed"
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn complete_decommission(&self, idx: usize) -> Result<()> {
|
|
self.complete_decommission_with_owner(idx, None, None, None).await
|
|
}
|
|
|
|
async fn complete_decommission_for_operation(
|
|
&self,
|
|
idx: usize,
|
|
owner: &DecommissionCanceler,
|
|
verified_generation: OffsetDateTime,
|
|
verified_unresolved_entries: Vec<DecommissionUnresolvedEntry>,
|
|
) -> Result<()> {
|
|
self.complete_decommission_with_owner(idx, Some(owner), Some(verified_generation), Some(verified_unresolved_entries))
|
|
.await
|
|
}
|
|
|
|
async fn complete_decommission_with_owner(
|
|
&self,
|
|
idx: usize,
|
|
owner: Option<&DecommissionCanceler>,
|
|
verified_generation: Option<OffsetDateTime>,
|
|
verified_unresolved_entries: Option<Vec<DecommissionUnresolvedEntry>>,
|
|
) -> Result<()> {
|
|
ensure_decommission_terminal_operation_supported(self.single_pool(), "complete decommission")?;
|
|
ensure_valid_decommission_pool_index(self.pools.len(), idx)?;
|
|
if let Some(owner) = owner {
|
|
let cancelers = self.decommission_cancelers.read().await;
|
|
if !decommission_canceler_is_owned_by(cancelers.as_slice(), idx, owner) {
|
|
owner.release();
|
|
return Ok(());
|
|
}
|
|
}
|
|
self.verify_decommission_durable_ilm_receipts(idx).await?;
|
|
let _start_guard = self.start_gate.lock().await;
|
|
let movement_gate = self.ctx.data_movement_operation_gate();
|
|
let _movement_guard = movement_gate.write().await;
|
|
|
|
// Lock order: movement gate, rebalance_meta, decommission_cancelers,
|
|
// then pool_meta. Holding both state locks makes owner validation and
|
|
// the terminal transition one atomic operation.
|
|
let rebalance_meta = self.rebalance_meta.read().await.clone();
|
|
let terminal_at = OffsetDateTime::now_utc();
|
|
let (should_reload_pool_meta, completed, previous_pool_meta, terminal_canceler) = {
|
|
let cancelers = self.decommission_cancelers.read().await;
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
let previous_pool_meta = pool_meta.clone();
|
|
let Some(changed) =
|
|
update_decommission_for_operation(cancelers.as_slice(), &mut pool_meta, idx, owner, |pool_meta| {
|
|
reconcile_decommission_unresolved_entries_for_completion(
|
|
pool_meta,
|
|
idx,
|
|
verified_generation,
|
|
verified_unresolved_entries.as_deref(),
|
|
)?;
|
|
Ok::<bool, Error>(pool_meta.decommission_complete_at(idx, terminal_at, rebalance_meta.as_ref()))
|
|
})
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let changed = changed?;
|
|
let completed = pool_meta
|
|
.pools
|
|
.get(idx)
|
|
.and_then(|pool| pool.decommission.as_ref())
|
|
.is_some_and(|decommission| decommission.complete);
|
|
let terminal_canceler = if let Some(owner) = owner {
|
|
Some(owner.clone())
|
|
} else {
|
|
cancelers.get(idx).and_then(Option::as_ref).cloned()
|
|
};
|
|
(changed, completed, changed.then_some(previous_pool_meta), terminal_canceler)
|
|
};
|
|
|
|
if should_reload_pool_meta && let Err(err) = self.save_current_pool_meta(&[idx]).await {
|
|
if let Some(previous_pool_meta) = previous_pool_meta {
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
rollback_decommission_pool_meta(&mut pool_meta, &previous_pool_meta, &[idx]);
|
|
}
|
|
return Err(err);
|
|
}
|
|
if should_reload_pool_meta {
|
|
{
|
|
let mut pool_meta = self.pool_meta.write().await;
|
|
pool_meta.mark_decommission_progress_saved();
|
|
}
|
|
}
|
|
if let Some(canceler) = terminal_canceler.as_ref() {
|
|
self.release_decommission_canceler_slot(idx, canceler).await;
|
|
}
|
|
|
|
if should_reload_pool_meta {
|
|
self.ctx.advance_data_movement_operation_epoch();
|
|
}
|
|
drop(_movement_guard);
|
|
|
|
if should_reload_pool_meta && let Some(notification_sys) = runtime_sources::notification_sys() {
|
|
let stage = format!("complete_decommission for pool {idx}");
|
|
if let Some(err) = observe_decommission_terminal_reload_result(
|
|
resolve_decommission_pool_meta_reload_result(notification_sys.reload_pool_meta().await, stage.as_str()),
|
|
stage.as_str(),
|
|
) {
|
|
if let Err(record_err) = self
|
|
.record_decommission_terminal_reload_failure(idx, stage.as_str(), err.clone())
|
|
.await
|
|
{
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "terminal_reload_record_failed",
|
|
error = %record_err,
|
|
original_error = %err,
|
|
"Decommission terminal reload failure record failed"
|
|
);
|
|
}
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "terminal_reload_failed",
|
|
error = %err,
|
|
"Decommission terminal state saved but pool meta reload failed"
|
|
);
|
|
}
|
|
}
|
|
|
|
if completed && let Err(err) = self.cleanup_decommission_durable_ilm_receipts(idx).await {
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
state = "receipt_cleanup_failed",
|
|
error = %err,
|
|
"Decommission durable ILM receipt cleanup failed"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn decommission_pending_bucket(
|
|
self: &Arc<Self>,
|
|
rx: CancellationToken,
|
|
idx: usize,
|
|
pool: Arc<Sets>,
|
|
bucket: DecomBucketInfo,
|
|
entry_budget: Arc<Semaphore>,
|
|
source_changed_exhaustions: Arc<AtomicUsize>,
|
|
) -> Result<()> {
|
|
let is_decommissioned = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
resolve_decommission_bucket_state(&pool_meta, idx, &bucket)?
|
|
};
|
|
|
|
if is_decommissioned {
|
|
warn!("decommission: already done, moving on {}", bucket.to_string());
|
|
|
|
self.mark_decommission_bucket_done_and_save(idx, &bucket).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
warn!("decommission: currently on bucket {}", &bucket.name);
|
|
|
|
if let Err(err) = self
|
|
.decommission_pool(rx.clone(), idx, pool, bucket.clone(), entry_budget, source_changed_exhaustions)
|
|
.await
|
|
{
|
|
error!("decommission: decommission_pool err {:?}", &err);
|
|
return Err(err);
|
|
} else {
|
|
warn!("decommission: decommission_pool done {}", &bucket.name);
|
|
}
|
|
|
|
if let Err(err) = decommission_cancel_signal_result(rx.is_cancelled()) {
|
|
warn!("decommission: cancellation observed after decommission_pool {}", &bucket.name);
|
|
return Err(err);
|
|
}
|
|
|
|
self.mark_decommission_bucket_done_and_save(idx, &bucket).await?;
|
|
|
|
warn!("decommission: decommission_pool bucket_done {}", &bucket.name);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument(skip(self, rx))]
|
|
async fn decommission_in_background(
|
|
self: &Arc<Self>,
|
|
rx: CancellationToken,
|
|
idx: usize,
|
|
generation: OffsetDateTime,
|
|
entry_budget: Arc<Semaphore>,
|
|
) -> Result<()> {
|
|
self.ensure_decommission_runtime_capacity_available(idx, generation).await?;
|
|
let pool = get_by_index(self.pools.as_slice(), idx, "load decommission background pool")?.clone();
|
|
|
|
let pending = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
pool_meta.pending_buckets(idx)
|
|
};
|
|
let source_changed_exhaustions = Arc::new(AtomicUsize::new(0));
|
|
let bucket_concurrency = decommission_bucket_concurrency_limit();
|
|
let (regular_buckets, meta_buckets) = split_decommission_buckets(pending);
|
|
let store = Arc::clone(self);
|
|
run_decommission_phases(rx.clone(), regular_buckets, meta_buckets, bucket_concurrency, move |bucket, rx| {
|
|
let store = Arc::clone(&store);
|
|
let pool = pool.clone();
|
|
let entry_budget = entry_budget.clone();
|
|
let source_changed_exhaustions = Arc::clone(&source_changed_exhaustions);
|
|
Box::pin(async move {
|
|
store
|
|
.decommission_pending_bucket(rx, idx, pool, bucket, entry_budget, source_changed_exhaustions)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tracing::instrument(skip(self))]
|
|
pub async fn start_decommission(&self, indices: Vec<usize>) -> Result<()> {
|
|
self.start_decommission_inner(indices, None).await.map(|_| ())
|
|
}
|
|
|
|
async fn start_decommission_with_routines(
|
|
&self,
|
|
indices: Vec<usize>,
|
|
rx: &CancellationToken,
|
|
local_indices: &[usize],
|
|
) -> Result<Vec<(usize, DecommissionCancelerGuard)>> {
|
|
self.start_decommission_inner(indices, Some((rx, local_indices))).await
|
|
}
|
|
|
|
async fn start_decommission_inner(
|
|
&self,
|
|
indices: Vec<usize>,
|
|
reservation: Option<(&CancellationToken, &[usize])>,
|
|
) -> Result<Vec<(usize, DecommissionCancelerGuard)>> {
|
|
let indices = dedup_indices(&indices);
|
|
validate_start_decommission_request(&indices, self.single_pool())?;
|
|
|
|
self.ensure_decommission_rebalance_idle_after_refresh().await?;
|
|
#[cfg(test)]
|
|
let endpoints = self.instance_endpoints().unwrap_or_else(|| self.endpoints());
|
|
#[cfg(not(test))]
|
|
let endpoints = self.endpoints();
|
|
ensure_decommission_start_local_leader(&endpoints, &indices)?;
|
|
|
|
for idx in indices.iter().copied() {
|
|
ensure_valid_decommission_pool_index(self.pools.len(), idx)?;
|
|
}
|
|
|
|
{
|
|
let pool_meta = self.pool_meta.read().await;
|
|
ensure_decommission_start_pool_states(&pool_meta, &indices)?;
|
|
ensure_decommission_ledger_persistence_supported(&pool_meta)?;
|
|
ensure_decommission_capacity_writer_supported(&pool_meta)?;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
observe_pool_activation_preflight_side_effect_attempt(PoolActivationStartKind::Decommission);
|
|
let decom_buckets = self.get_buckets_to_decommission().await?;
|
|
|
|
let mut healed_buckets = HashSet::with_capacity(decom_buckets.len());
|
|
for bk in decom_buckets.iter() {
|
|
if healed_buckets.insert(bk.name.as_str()) {
|
|
resolve_decommission_preflight_heal_result(&bk.name, self.heal_bucket(&bk.name, &HealOpts::default()).await)?;
|
|
}
|
|
}
|
|
|
|
let meta_bucket_opts = decommission_meta_bucket_options();
|
|
for prefix in DECOMMISSION_META_PREFIXES {
|
|
let bk = path_join(&[PathBuf::from(RUSTFS_META_BUCKET), PathBuf::from(prefix)]);
|
|
if let Err(err) = self
|
|
.make_bucket(bk.to_string_lossy().to_string().as_str(), &meta_bucket_opts)
|
|
.await
|
|
&& !is_err_bucket_exists(&err)
|
|
{
|
|
error!("decommission: make bucket failed: {err}");
|
|
return Err(err);
|
|
}
|
|
}
|
|
|
|
let _start_guard = self.start_gate.lock().await;
|
|
self.ensure_decommission_rebalance_idle_after_refresh_under_start_gate()
|
|
.await?;
|
|
|
|
let all_capacity_infos = self.get_decommission_all_pool_capacity_infos().await?;
|
|
let target_fence_proof_available =
|
|
crate::services::notification_sys::acquire_decommission_target_fence_fleet_proof().is_some();
|
|
// Signal cancellation before waiting for the movement writer so active
|
|
// object operations can observe the signal and release read guards.
|
|
self.cancel_decommission_routines(&indices).await;
|
|
let movement_gate = self.ctx.data_movement_operation_gate();
|
|
let _movement_guard = movement_gate.write().await;
|
|
|
|
let index_cancelers = if let Some((rx, local_indices)) = reservation {
|
|
// Lock order matches terminal transitions: movement gate, then
|
|
// decommission_cancelers, then pool_meta while start_gate excludes
|
|
// another start.
|
|
let mut cancelers = self.decommission_cancelers.write().await;
|
|
let pool_meta = self.pool_meta.read().await;
|
|
ensure_decommission_start_target_capacity(&pool_meta, &indices, &all_capacity_infos, target_fence_proof_available)?;
|
|
reserve_decommission_start_cancelers(&pool_meta, &indices, local_indices, rx, cancelers.as_mut_slice())?
|
|
} else {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
ensure_decommission_start_pool_states(&pool_meta, &indices)?;
|
|
ensure_decommission_start_target_capacity(&pool_meta, &indices, &all_capacity_infos, target_fence_proof_available)?;
|
|
Vec::new()
|
|
};
|
|
|
|
let previous_pool_meta = self
|
|
.save_current_pool_meta_for_decommission_start(&indices, decom_buckets)
|
|
.await?;
|
|
self.ctx.advance_data_movement_operation_epoch();
|
|
// The local durable transition is now fenced. Release the writer
|
|
// before any peer RPC; remote reload must not block scanner admission.
|
|
drop(_movement_guard);
|
|
|
|
if let Some(notification_sys) = runtime_sources::notification_sys()
|
|
&& let Err(err) = resolve_start_decommission_pool_meta_reload_result(notification_sys.reload_pool_meta().await)
|
|
{
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "start_failed",
|
|
stage = "reload_pool_meta",
|
|
error = %err,
|
|
"Decommission start failed after pool metadata save"
|
|
);
|
|
|
|
let rollback_result = self
|
|
.rollback_decommission_start_after_reload_failure(&movement_gate, &previous_pool_meta, &indices)
|
|
.await;
|
|
if let Err(rollback_save_err) = rollback_result {
|
|
error!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "rollback_failed",
|
|
stage = "save_pool_meta",
|
|
error = %rollback_save_err,
|
|
original_error = %err,
|
|
"Decommission rollback failed after pool metadata reload failure"
|
|
);
|
|
return Err(Error::other(format!(
|
|
"{err}; decommission start rollback save failed: {rollback_save_err}"
|
|
)));
|
|
}
|
|
|
|
if let Err(rollback_reload_err) = resolve_decommission_pool_meta_reload_result(
|
|
notification_sys.reload_pool_meta().await,
|
|
"start_decommission_rollback",
|
|
) {
|
|
error!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "rollback_partial",
|
|
stage = "reload_pool_meta",
|
|
error = %rollback_reload_err,
|
|
original_error = %err,
|
|
"Decommission rollback metadata reload failed after local rollback save"
|
|
);
|
|
return Err(Error::other(format!(
|
|
"{err}; decommission start rollback saved locally but peer reload failed: {rollback_reload_err}"
|
|
)));
|
|
}
|
|
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_STATE,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
state = "rollback_success",
|
|
original_error = %err,
|
|
"Decommission start rolled back after pool metadata reload failure"
|
|
);
|
|
return Err(Error::other(format!("{err}; decommission start rollback succeeded")));
|
|
}
|
|
|
|
Ok(index_cancelers)
|
|
}
|
|
|
|
async fn get_buckets_to_decommission(&self) -> Result<Vec<DecomBucketInfo>> {
|
|
let buckets = self.list_bucket(&BucketOptions::default()).await?;
|
|
|
|
let mut ret: Vec<DecomBucketInfo> = buckets
|
|
.iter()
|
|
.map(|v| DecomBucketInfo {
|
|
name: v.name.clone(),
|
|
..Default::default()
|
|
})
|
|
.collect();
|
|
|
|
ret.extend(decommission_meta_buckets());
|
|
|
|
Ok(ret)
|
|
}
|
|
|
|
async fn durable_ilm_receipt_run_token(&self, source_pool_idx: usize) -> Result<String> {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
let pool = pool_meta
|
|
.pools
|
|
.get(source_pool_idx)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_meta.pools.len(), source_pool_idx))?;
|
|
let start_time = pool
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.start_time)
|
|
.ok_or_else(|| Error::other(format!("decommission run identity is missing for pool {source_pool_idx}")))?;
|
|
Ok(decommission_durable_ilm_receipt_run_token(&pool.cmd_line, start_time))
|
|
}
|
|
|
|
async fn durable_ilm_receipt_run_token_for_generation(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
generation: OffsetDateTime,
|
|
) -> Result<String> {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
ensure_decommission_generation(&pool_meta, source_pool_idx, generation)?;
|
|
let pool = pool_meta
|
|
.pools
|
|
.get(source_pool_idx)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(pool_meta.pools.len(), source_pool_idx))?;
|
|
Ok(decommission_durable_ilm_receipt_run_token(&pool.cmd_line, generation))
|
|
}
|
|
|
|
async fn load_decommissioned_durable_ilm_target(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
path: &str,
|
|
max_record_size: usize,
|
|
record_context: &str,
|
|
) -> Result<Option<(usize, Vec<u8>)>> {
|
|
let mut target = None::<(usize, Vec<u8>)>;
|
|
let mut first_read_error = None;
|
|
for (target_pool_idx, pool) in self.pools.iter().enumerate() {
|
|
if target_pool_idx == source_pool_idx {
|
|
continue;
|
|
}
|
|
match read_config_limited_preserve_empty(pool.clone(), path, max_record_size).await {
|
|
Ok(data) => {
|
|
if let Some((existing_pool_idx, existing)) = target.as_ref()
|
|
&& existing != &data
|
|
{
|
|
return Err(Error::other(format!(
|
|
"divergent target durable ILM records at path `{path}` {record_context} in pools {existing_pool_idx} and {target_pool_idx}"
|
|
)));
|
|
}
|
|
target = Some((target_pool_idx, data));
|
|
}
|
|
Err(err)
|
|
if matches!(&err, Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound)
|
|
|| is_err_object_not_found(&err)
|
|
|| is_err_version_not_found(&err) => {}
|
|
Err(err) => {
|
|
first_read_error.get_or_insert_with(|| {
|
|
Error::other(format!(
|
|
"failed to read target durable ILM record at path `{path}` {record_context} from pool {target_pool_idx}: {err}"
|
|
))
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(err) = first_read_error {
|
|
return Err(err);
|
|
}
|
|
Ok(target)
|
|
}
|
|
|
|
async fn list_decommission_durable_ilm_receipt_paths_in_pool(&self, pool_idx: usize, prefix: &str) -> Result<Vec<String>> {
|
|
let pool = self
|
|
.pools
|
|
.get(pool_idx)
|
|
.ok_or_else(|| invalid_decommission_pool_index_error(self.pools.len(), pool_idx))?;
|
|
let mut receipts = Vec::new();
|
|
let mut continuation = None;
|
|
loop {
|
|
let page = pool
|
|
.clone()
|
|
.list_objects_v2(RUSTFS_META_BUCKET, prefix, continuation, None, 1000, false, None, false)
|
|
.await
|
|
.map_err(|err| {
|
|
Error::other(format!(
|
|
"failed to list durable ILM decommission receipts under `{prefix}` in pool {pool_idx}: {err}"
|
|
))
|
|
})?;
|
|
receipts.extend(page.objects.into_iter().map(|object| object.name));
|
|
if !page.is_truncated {
|
|
break;
|
|
}
|
|
continuation = Some(page.next_continuation_token.ok_or_else(|| {
|
|
Error::other(format!(
|
|
"durable ILM decommission receipt listing under `{prefix}` in pool {pool_idx} was truncated without a continuation token"
|
|
))
|
|
})?);
|
|
}
|
|
Ok(receipts)
|
|
}
|
|
|
|
async fn list_decommission_durable_ilm_receipts(&self, source_pool_idx: usize) -> Result<Vec<(usize, String)>> {
|
|
let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?;
|
|
self.list_decommission_durable_ilm_receipts_for_run(source_pool_idx, &run_token)
|
|
.await
|
|
}
|
|
|
|
async fn list_decommission_durable_ilm_receipts_for_run(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
run_token: &str,
|
|
) -> Result<Vec<(usize, String)>> {
|
|
let prefix = decommission_durable_ilm_receipt_run_prefix(run_token);
|
|
let mut receipts = Vec::new();
|
|
for pool_idx in 0..self.pools.len() {
|
|
if pool_idx == source_pool_idx {
|
|
continue;
|
|
}
|
|
for receipt_path in self
|
|
.list_decommission_durable_ilm_receipt_paths_in_pool(pool_idx, &prefix)
|
|
.await?
|
|
{
|
|
let locator = parse_decommission_durable_ilm_receipt_path(&receipt_path)?;
|
|
if locator.run_token != run_token {
|
|
return Err(Error::other(format!(
|
|
"durable ILM receipt path `{receipt_path}` has an unexpected run token"
|
|
)));
|
|
}
|
|
receipts.push((pool_idx, receipt_path));
|
|
}
|
|
}
|
|
Ok(receipts)
|
|
}
|
|
|
|
async fn list_decommission_durable_ilm_manifest_receipts(&self, source_pool_idx: usize) -> Result<Vec<String>> {
|
|
let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?;
|
|
self.list_decommission_durable_ilm_manifest_receipts_for_run(source_pool_idx, &run_token)
|
|
.await
|
|
}
|
|
|
|
async fn list_decommission_durable_ilm_manifest_receipts_for_run(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
run_token: &str,
|
|
) -> Result<Vec<String>> {
|
|
let prefix = decommission_durable_ilm_receipt_run_prefix(run_token);
|
|
let receipt_paths = self
|
|
.list_decommission_durable_ilm_receipt_paths_in_pool(source_pool_idx, &prefix)
|
|
.await?;
|
|
for receipt_path in &receipt_paths {
|
|
let locator = parse_decommission_durable_ilm_receipt_path(receipt_path)?;
|
|
if locator.run_token != run_token {
|
|
return Err(Error::other(format!(
|
|
"durable ILM expected manifest receipt path `{receipt_path}` has an unexpected run token"
|
|
)));
|
|
}
|
|
}
|
|
Ok(receipt_paths)
|
|
}
|
|
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
async fn persist_decommission_durable_ilm_manifest(&self, source_pool_idx: usize) -> Result<()> {
|
|
let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?;
|
|
self.persist_decommission_durable_ilm_manifest_for_run(source_pool_idx, &run_token)
|
|
.await
|
|
}
|
|
|
|
async fn persist_decommission_durable_ilm_manifest_for_run(&self, source_pool_idx: usize, run_token: &str) -> Result<()> {
|
|
let receipt_paths = self
|
|
.list_decommission_durable_ilm_manifest_receipts_for_run(source_pool_idx, run_token)
|
|
.await?;
|
|
for receipt_path in &receipt_paths {
|
|
self.read_decommission_durable_ilm_receipt(source_pool_idx, receipt_path)
|
|
.await?;
|
|
}
|
|
let manifest = DecommissionDurableIlmManifest::new(run_token, &receipt_paths)?;
|
|
let manifest_path = decommission_durable_ilm_manifest_path(run_token);
|
|
let encoded = manifest.encode()?;
|
|
let mut attempt = 1;
|
|
loop {
|
|
match read_config_limited_preserve_empty(
|
|
self.pools[source_pool_idx].clone(),
|
|
&manifest_path,
|
|
DECOMMISSION_DURABLE_ILM_MANIFEST_MAX_SIZE,
|
|
)
|
|
.await
|
|
{
|
|
Ok(existing) => {
|
|
DecommissionDurableIlmManifest::decode(&existing, run_token, &receipt_paths).map_err(|err| {
|
|
Error::other(format!(
|
|
"durable ILM expected manifest `{manifest_path}` in source pool {source_pool_idx} is invalid: {err}"
|
|
))
|
|
})?;
|
|
return Ok(());
|
|
}
|
|
Err(err)
|
|
if matches!(&err, Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound)
|
|
|| is_err_object_not_found(&err)
|
|
|| is_err_version_not_found(&err) => {}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"failed to read durable ILM expected manifest `{manifest_path}` from source pool {source_pool_idx}: {err}"
|
|
)));
|
|
}
|
|
}
|
|
match save_config_with_opts(
|
|
self.pools[source_pool_idx].clone(),
|
|
&manifest_path,
|
|
encoded.clone(),
|
|
&ObjectOptions {
|
|
max_parity: true,
|
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
|
http_preconditions: Some(HTTPPreconditions {
|
|
if_none_match: Some("*".to_string()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => return Ok(()),
|
|
Err(Error::PreconditionFailed) if attempt < DECOMMISSION_DURABLE_ILM_RECEIPT_CAS_ATTEMPTS => {
|
|
attempt += 1;
|
|
}
|
|
Err(Error::PreconditionFailed) => {
|
|
return Err(Error::other(format!(
|
|
"failed to persist durable ILM expected manifest `{manifest_path}` after concurrent updates"
|
|
)));
|
|
}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"failed to persist durable ILM expected manifest `{manifest_path}` in source pool {source_pool_idx}: {err}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn load_decommission_durable_ilm_manifest_for_run(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
run_token: &str,
|
|
) -> Result<HashMap<String, DecommissionDurableIlmReceipt>> {
|
|
let receipt_paths = self
|
|
.list_decommission_durable_ilm_manifest_receipts_for_run(source_pool_idx, run_token)
|
|
.await?;
|
|
let manifest_path = decommission_durable_ilm_manifest_path(run_token);
|
|
let data = read_config_limited_preserve_empty(
|
|
self.pools[source_pool_idx].clone(),
|
|
&manifest_path,
|
|
DECOMMISSION_DURABLE_ILM_MANIFEST_MAX_SIZE,
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
Error::other(format!(
|
|
"failed to read durable ILM expected manifest `{manifest_path}` from source pool {source_pool_idx}: {err}"
|
|
))
|
|
})?;
|
|
DecommissionDurableIlmManifest::decode(&data, run_token, &receipt_paths).map_err(|err| {
|
|
Error::other(format!(
|
|
"durable ILM expected manifest `{manifest_path}` in source pool {source_pool_idx} is invalid: {err}"
|
|
))
|
|
})?;
|
|
|
|
let mut receipts = HashMap::with_capacity(receipt_paths.len());
|
|
for receipt_path in receipt_paths {
|
|
let receipt = self
|
|
.read_decommission_durable_ilm_receipt(source_pool_idx, &receipt_path)
|
|
.await?;
|
|
if receipts.insert(receipt_path.clone(), receipt).is_some() {
|
|
return Err(Error::other(format!(
|
|
"durable ILM expected manifest contains duplicate receipt path `{receipt_path}`"
|
|
)));
|
|
}
|
|
}
|
|
Ok(receipts)
|
|
}
|
|
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
async fn persist_decommission_durable_ilm_receipt(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
target_pool_idx: usize,
|
|
receipt: &DecommissionDurableIlmReceipt,
|
|
) -> Result<()> {
|
|
let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?;
|
|
self.persist_decommission_durable_ilm_receipt_for_run(target_pool_idx, receipt, &run_token)
|
|
.await
|
|
}
|
|
|
|
async fn persist_decommission_durable_ilm_receipt_for_run(
|
|
&self,
|
|
target_pool_idx: usize,
|
|
receipt: &DecommissionDurableIlmReceipt,
|
|
run_token: &str,
|
|
) -> Result<()> {
|
|
let receipt_path = decommission_durable_ilm_receipt_path(run_token, &receipt.source_path, &receipt.id_kind, &receipt.id);
|
|
let locator = parse_decommission_durable_ilm_receipt_path(&receipt_path)?;
|
|
let mut attempt = 1;
|
|
loop {
|
|
let (merged, http_preconditions) = match read_config_limited_preserve_empty_with_metadata(
|
|
self.pools[target_pool_idx].clone(),
|
|
&receipt_path,
|
|
DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE,
|
|
)
|
|
.await
|
|
{
|
|
Ok((existing_data, metadata)) => {
|
|
let existing = DecommissionDurableIlmReceipt::decode(&existing_data).map_err(|err| {
|
|
Error::other(format!(
|
|
"durable ILM decommission receipt `{receipt_path}` in pool {target_pool_idx} for {} is invalid: {err}",
|
|
locator.context()
|
|
))
|
|
})?;
|
|
Self::validate_decommission_durable_ilm_receipt_locator(&receipt_path, &locator, &existing)?;
|
|
let merged = merge_decommission_durable_ilm_receipts(&existing, receipt)?;
|
|
if merged == existing {
|
|
return Ok(());
|
|
}
|
|
let etag = metadata.etag.filter(|etag| !etag.trim().is_empty()).ok_or_else(|| {
|
|
Error::other(format!(
|
|
"durable ILM decommission receipt `{receipt_path}` in pool {target_pool_idx} is missing an ETag"
|
|
))
|
|
})?;
|
|
(
|
|
merged,
|
|
HTTPPreconditions {
|
|
if_match: Some(etag),
|
|
..Default::default()
|
|
},
|
|
)
|
|
}
|
|
Err(err)
|
|
if matches!(&err, Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound)
|
|
|| is_err_object_not_found(&err)
|
|
|| is_err_version_not_found(&err) =>
|
|
{
|
|
(
|
|
receipt.clone(),
|
|
HTTPPreconditions {
|
|
if_none_match: Some("*".to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"failed to read durable ILM decommission receipt `{receipt_path}` from pool {target_pool_idx} for {}: {err}",
|
|
locator.context()
|
|
)));
|
|
}
|
|
};
|
|
let encoded = merged.encode().map_err(|err| {
|
|
Error::other(format!(
|
|
"failed to encode durable ILM decommission receipt `{receipt_path}` for source path `{}` {}: {err}",
|
|
receipt.source_path,
|
|
receipt.context()
|
|
))
|
|
})?;
|
|
match save_config_with_opts(
|
|
self.pools[target_pool_idx].clone(),
|
|
&receipt_path,
|
|
encoded,
|
|
&ObjectOptions {
|
|
max_parity: true,
|
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
|
http_preconditions: Some(http_preconditions),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => return Ok(()),
|
|
Err(Error::PreconditionFailed) if attempt < DECOMMISSION_DURABLE_ILM_RECEIPT_CAS_ATTEMPTS => {
|
|
attempt += 1;
|
|
}
|
|
Err(Error::PreconditionFailed) => {
|
|
return Err(Error::other(format!(
|
|
"failed to persist durable ILM decommission receipt `{receipt_path}` for {} after concurrent updates",
|
|
locator.context()
|
|
)));
|
|
}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"failed to persist durable ILM decommission receipt `{receipt_path}` for {}: {err}",
|
|
locator.context()
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn validate_decommission_durable_ilm_receipt_locator(
|
|
receipt_path: &str,
|
|
locator: &DecommissionDurableIlmReceiptLocator,
|
|
receipt: &DecommissionDurableIlmReceipt,
|
|
) -> Result<()> {
|
|
if locator.source_path != receipt.source_path || locator.id_kind != receipt.id_kind || locator.id != receipt.id {
|
|
return Err(Error::other(format!(
|
|
"durable ILM decommission receipt path `{receipt_path}` identity {} does not match receipt {}",
|
|
locator.context(),
|
|
receipt.context()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn read_decommission_durable_ilm_receipt(
|
|
&self,
|
|
receipt_pool_idx: usize,
|
|
receipt_path: &str,
|
|
) -> Result<DecommissionDurableIlmReceipt> {
|
|
let locator = parse_decommission_durable_ilm_receipt_path(receipt_path)?;
|
|
let data = read_config_limited_preserve_empty(
|
|
self.pools[receipt_pool_idx].clone(),
|
|
receipt_path,
|
|
DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE,
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
Error::other(format!(
|
|
"failed to read durable ILM decommission receipt `{receipt_path}` from pool {receipt_pool_idx} for {}: {err}",
|
|
locator.context()
|
|
))
|
|
})?;
|
|
let receipt = DecommissionDurableIlmReceipt::decode(&data).map_err(|err| {
|
|
Error::other(format!(
|
|
"durable ILM decommission receipt `{receipt_path}` in pool {receipt_pool_idx} for {} is invalid: {err}",
|
|
locator.context()
|
|
))
|
|
})?;
|
|
Self::validate_decommission_durable_ilm_receipt_locator(receipt_path, &locator, &receipt)?;
|
|
Ok(receipt)
|
|
}
|
|
|
|
async fn load_decommission_durable_ilm_terminal_receipt_for_run(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
path: &str,
|
|
source_record: &ValidatedDurableIlmRecord,
|
|
run_token: &str,
|
|
) -> Result<Option<DecommissionDurableIlmReceipt>> {
|
|
let receipt_path = decommission_durable_ilm_receipt_path(run_token, path, source_record.id_kind, &source_record.id);
|
|
let locator = parse_decommission_durable_ilm_receipt_path(&receipt_path)?;
|
|
let mut proof = None::<DecommissionDurableIlmReceipt>;
|
|
let mut nonterminal_receipt_found = false;
|
|
for pool_idx in 0..self.pools.len() {
|
|
if pool_idx == source_pool_idx {
|
|
continue;
|
|
}
|
|
let data = match read_config_limited_preserve_empty(
|
|
self.pools[pool_idx].clone(),
|
|
&receipt_path,
|
|
DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE,
|
|
)
|
|
.await
|
|
{
|
|
Ok(data) => data,
|
|
Err(err)
|
|
if matches!(&err, Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound)
|
|
|| is_err_object_not_found(&err)
|
|
|| is_err_version_not_found(&err) =>
|
|
{
|
|
continue;
|
|
}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"failed to read terminal durable ILM decommission receipt `{receipt_path}` from pool {pool_idx} for {}: {err}",
|
|
source_record.context()
|
|
)));
|
|
}
|
|
};
|
|
let receipt = DecommissionDurableIlmReceipt::decode(&data).map_err(|err| {
|
|
Error::other(format!(
|
|
"terminal durable ILM decommission receipt `{receipt_path}` in pool {pool_idx} for {} is invalid: {err}",
|
|
source_record.context()
|
|
))
|
|
})?;
|
|
Self::validate_decommission_durable_ilm_receipt_locator(&receipt_path, &locator, &receipt)?;
|
|
if receipt.namespace != source_record.namespace
|
|
|| receipt.id_kind != source_record.id_kind
|
|
|| receipt.id != source_record.id
|
|
{
|
|
return Err(Error::other(format!(
|
|
"terminal durable ILM decommission receipt identity mismatch at path `{path}` {}; receipt {}",
|
|
source_record.context(),
|
|
receipt.context()
|
|
)));
|
|
}
|
|
if let Some(terminal_checkpoint) = receipt.terminal_checkpoint.as_ref() {
|
|
if !source_record.checkpoint.is_predecessor_of_terminal(terminal_checkpoint) {
|
|
return Err(Error::other_with_context(
|
|
"terminal durable ILM decommission receipt does not cover source",
|
|
format!("path `{path}` {}", source_record.context()),
|
|
));
|
|
}
|
|
proof = Some(match proof {
|
|
Some(existing) => merge_decommission_durable_ilm_receipts(&existing, &receipt)?,
|
|
None => receipt,
|
|
});
|
|
} else {
|
|
source_record
|
|
.checkpoint
|
|
.validate_successor(&receipt.checkpoint)
|
|
.map_err(|err| {
|
|
Error::other_with_context(
|
|
"durable ILM decommission receipt does not cover source",
|
|
format!("path `{path}` {}: {err}", source_record.context()),
|
|
)
|
|
})?;
|
|
nonterminal_receipt_found = true;
|
|
}
|
|
}
|
|
// A terminal receipt on one target must not hide another target copy
|
|
// whose receipt was installed later and has not reached terminal yet.
|
|
// Returning no proof makes recovery advance every outstanding copy
|
|
// before source cleanup can treat the operation as complete.
|
|
if nonterminal_receipt_found { Ok(None) } else { Ok(proof) }
|
|
}
|
|
|
|
async fn verify_decommission_durable_ilm_receipts(&self, source_pool_idx: usize) -> Result<()> {
|
|
let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?;
|
|
self.verify_decommission_durable_ilm_receipts_for_run(source_pool_idx, &run_token)
|
|
.await
|
|
}
|
|
|
|
async fn verify_decommission_durable_ilm_receipts_for_run(&self, source_pool_idx: usize, run_token: &str) -> Result<()> {
|
|
let expected_receipts = self
|
|
.load_decommission_durable_ilm_manifest_for_run(source_pool_idx, run_token)
|
|
.await?;
|
|
let receipt_paths = self
|
|
.list_decommission_durable_ilm_receipts_for_run(source_pool_idx, run_token)
|
|
.await?;
|
|
let present_receipt_paths = receipt_paths
|
|
.iter()
|
|
.map(|(_, receipt_path)| receipt_path.as_str())
|
|
.collect::<HashSet<_>>();
|
|
for (expected_path, expected) in &expected_receipts {
|
|
if !present_receipt_paths.contains(expected_path.as_str()) {
|
|
return Err(Error::other(format!(
|
|
"durable ILM decommission receipt is missing at `{expected_path}` for source path `{}` {}",
|
|
expected.source_path,
|
|
expected.context()
|
|
)));
|
|
}
|
|
}
|
|
|
|
for (receipt_pool_idx, receipt_path) in receipt_paths {
|
|
let expected = expected_receipts.get(&receipt_path).ok_or_else(|| {
|
|
Error::other(format!(
|
|
"durable ILM decommission receipt `{receipt_path}` in pool {receipt_pool_idx} is absent from the expected manifest"
|
|
))
|
|
})?;
|
|
let receipt = self
|
|
.read_decommission_durable_ilm_receipt(receipt_pool_idx, &receipt_path)
|
|
.await?;
|
|
if receipt.source_path != expected.source_path
|
|
|| receipt.namespace != expected.namespace
|
|
|| receipt.id_kind != expected.id_kind
|
|
|| receipt.id != expected.id
|
|
{
|
|
return Err(Error::other(format!(
|
|
"durable ILM decommission receipt identity mismatch at `{receipt_path}` for source path `{}` {}; decoded {}",
|
|
expected.source_path,
|
|
expected.context(),
|
|
receipt.context()
|
|
)));
|
|
}
|
|
expected.checkpoint.validate_successor(&receipt.checkpoint).map_err(|err| {
|
|
Error::other(format!(
|
|
"durable ILM decommission receipt generation mismatch at `{receipt_path}` for source path `{}` {}: {err}",
|
|
expected.source_path,
|
|
expected.context()
|
|
))
|
|
})?;
|
|
match (&expected.terminal_checkpoint, &receipt.terminal_checkpoint) {
|
|
(Some(expected_terminal), Some(receipt_terminal)) => {
|
|
expected_terminal.validate_successor(receipt_terminal).map_err(|err| {
|
|
Error::other(format!(
|
|
"durable ILM decommission terminal receipt generation mismatch at `{receipt_path}` for source path `{}` {}: {err}",
|
|
expected.source_path,
|
|
expected.context()
|
|
))
|
|
})?;
|
|
}
|
|
(Some(_), None) => {
|
|
return Err(Error::other(format!(
|
|
"durable ILM decommission terminal receipt is missing at `{receipt_path}` for source path `{}` {}",
|
|
expected.source_path,
|
|
expected.context()
|
|
)));
|
|
}
|
|
(None, _) => {}
|
|
}
|
|
let namespace = classify_durable_ilm_record(&receipt.source_path)?
|
|
.ok_or_else(|| Error::other(format!("path `{}` is not a durable ILM record", receipt.source_path)))?;
|
|
let target = self
|
|
.load_decommissioned_durable_ilm_target(
|
|
source_pool_idx,
|
|
&receipt.source_path,
|
|
namespace.max_record_size,
|
|
&receipt.context(),
|
|
)
|
|
.await?;
|
|
if let Some((_, target)) = target {
|
|
let target_record = validate_durable_ilm_record(&receipt.source_path, &target).map_err(|err| {
|
|
Error::other(format!(
|
|
"target durable ILM record is invalid at path `{}` {}: {err}",
|
|
receipt.source_path,
|
|
receipt.context()
|
|
))
|
|
})?;
|
|
let identity_matches = target_record.namespace == receipt.namespace
|
|
&& target_record.id_kind == receipt.id_kind
|
|
&& target_record.id == receipt.id;
|
|
let reused_manual_scope = receipt.terminal_checkpoint.is_some()
|
|
&& matches!(
|
|
(&receipt.checkpoint, &target_record.checkpoint),
|
|
(
|
|
DurableIlmRecordCheckpoint::ManualTransitionScope { .. },
|
|
DurableIlmRecordCheckpoint::ManualTransitionScope { .. }
|
|
)
|
|
);
|
|
if !identity_matches && !reused_manual_scope {
|
|
return Err(Error::other(format!(
|
|
"target durable ILM record identity mismatch at path `{}` {}; decoded {}",
|
|
receipt.source_path,
|
|
receipt.context(),
|
|
target_record.context()
|
|
)));
|
|
}
|
|
if identity_matches {
|
|
receipt
|
|
.terminal_checkpoint
|
|
.as_ref()
|
|
.unwrap_or(&receipt.checkpoint)
|
|
.validate_successor(&target_record.checkpoint)
|
|
.map_err(|err| {
|
|
Error::other(format!(
|
|
"target durable ILM record generation mismatch at path `{}` {}: {err}",
|
|
receipt.source_path,
|
|
receipt.context()
|
|
))
|
|
})?;
|
|
}
|
|
} else if receipt.terminal_checkpoint.is_none() {
|
|
return Err(Error::other(format!(
|
|
"target durable ILM record is missing at path `{}` {} without a recovery terminal checkpoint",
|
|
receipt.source_path,
|
|
receipt.context()
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn advance_durable_ilm_decommission_receipt(
|
|
&self,
|
|
pool_idx: usize,
|
|
receipt_path: &str,
|
|
record: &ValidatedDurableIlmRecord,
|
|
fleet_topology_generation: Option<&str>,
|
|
terminal: bool,
|
|
) -> Result<bool> {
|
|
let stage = if terminal { "terminal" } else { "progress" };
|
|
let locator = parse_decommission_durable_ilm_receipt_path(receipt_path)?;
|
|
let mut attempt = 1;
|
|
loop {
|
|
let (receipt_data, metadata) = match read_config_limited_preserve_empty_with_metadata(
|
|
self.pools[pool_idx].clone(),
|
|
receipt_path,
|
|
DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE,
|
|
)
|
|
.await
|
|
{
|
|
Ok(receipt) => receipt,
|
|
Err(err)
|
|
if matches!(&err, Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound)
|
|
|| is_err_object_not_found(&err)
|
|
|| is_err_version_not_found(&err) =>
|
|
{
|
|
return Ok(false);
|
|
}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"failed to read durable ILM decommission receipt `{receipt_path}` from pool {pool_idx} for {}: {err}",
|
|
locator.context()
|
|
)));
|
|
}
|
|
};
|
|
let mut receipt = DecommissionDurableIlmReceipt::decode(&receipt_data).map_err(|err| {
|
|
Error::other(format!(
|
|
"durable ILM decommission receipt `{receipt_path}` in pool {pool_idx} for {} is invalid: {err}",
|
|
locator.context()
|
|
))
|
|
})?;
|
|
Self::validate_decommission_durable_ilm_receipt_locator(receipt_path, &locator, &receipt)?;
|
|
if receipt.fleet_topology_generation.as_deref() != fleet_topology_generation {
|
|
return Err(Error::other_with_context(
|
|
"durable ILM decommission receipt fleet topology mismatch",
|
|
format!("path `{}` {}", receipt.source_path, receipt.context()),
|
|
));
|
|
}
|
|
receipt.checkpoint.validate_successor(&record.checkpoint).map_err(|err| {
|
|
Error::other(format!(
|
|
"{stage} durable ILM record generation mismatch at path `{}` {}: {err}",
|
|
receipt.source_path,
|
|
receipt.context()
|
|
))
|
|
})?;
|
|
|
|
if terminal {
|
|
if let Some(existing) = &receipt.terminal_checkpoint {
|
|
if existing == &record.checkpoint || record.checkpoint.validate_successor(existing).is_ok() {
|
|
return Ok(true);
|
|
}
|
|
existing.validate_successor(&record.checkpoint).map_err(|err| {
|
|
Error::other(format!(
|
|
"terminal durable ILM record checkpoint conflicts at path `{}` {}: {err}",
|
|
receipt.source_path,
|
|
receipt.context()
|
|
))
|
|
})?;
|
|
}
|
|
receipt.terminal_checkpoint = Some(record.checkpoint.clone());
|
|
} else {
|
|
if let Some(existing) = &receipt.terminal_checkpoint {
|
|
if existing == &record.checkpoint {
|
|
return Ok(true);
|
|
}
|
|
existing.validate_successor(&record.checkpoint).map_err(|err| {
|
|
Error::other(format!(
|
|
"progress durable ILM record conflicts with terminal checkpoint at path `{}` {}: {err}",
|
|
receipt.source_path,
|
|
receipt.context()
|
|
))
|
|
})?;
|
|
receipt.terminal_checkpoint = None;
|
|
}
|
|
if receipt.checkpoint == record.checkpoint {
|
|
return Ok(true);
|
|
}
|
|
receipt.checkpoint = record.checkpoint.clone();
|
|
}
|
|
|
|
let etag = metadata.etag.filter(|etag| !etag.trim().is_empty()).ok_or_else(|| {
|
|
Error::other(format!(
|
|
"durable ILM decommission receipt `{receipt_path}` in pool {pool_idx} is missing an ETag"
|
|
))
|
|
})?;
|
|
let encoded = receipt.encode()?;
|
|
match save_config_with_opts(
|
|
self.pools[pool_idx].clone(),
|
|
receipt_path,
|
|
encoded,
|
|
&ObjectOptions {
|
|
max_parity: true,
|
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
|
http_preconditions: Some(HTTPPreconditions {
|
|
if_match: Some(etag),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => return Ok(true),
|
|
Err(Error::PreconditionFailed) if attempt < DECOMMISSION_DURABLE_ILM_RECEIPT_CAS_ATTEMPTS => {
|
|
attempt += 1;
|
|
continue;
|
|
}
|
|
Err(Error::PreconditionFailed) => {
|
|
return Err(Error::other(format!(
|
|
"failed to persist {stage} durable ILM decommission receipt `{receipt_path}` for {} after concurrent updates",
|
|
locator.context()
|
|
)));
|
|
}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"failed to persist {stage} durable ILM decommission receipt `{receipt_path}` for {}: {err}",
|
|
locator.context()
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn advance_durable_ilm_decommission_receipts(
|
|
&self,
|
|
path: &str,
|
|
data: &[u8],
|
|
terminal: bool,
|
|
) -> Result<Option<Vec<usize>>> {
|
|
let active_runs = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
pool_meta
|
|
.pools
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(pool_idx, pool)| {
|
|
pool.decommission
|
|
.as_ref()
|
|
.filter(|info| info.has_decommission_state() && !info.complete)
|
|
.and_then(|info| info.start_time)
|
|
.map(|start_time| (pool_idx, decommission_durable_ilm_receipt_run_token(&pool.cmd_line, start_time)))
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
if active_runs.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let stage = if terminal { "terminal" } else { "progress" };
|
|
let record = validate_durable_ilm_record(path, data)
|
|
.map_err(|err| Error::other(format!("{stage} durable ILM record is invalid at path `{path}`: {err}")))?;
|
|
let fleet_topology_generation = durable_ilm_v6_topology_generation(path, data)?;
|
|
let active_source_pool_indices = active_runs.iter().map(|(pool_idx, _)| *pool_idx).collect::<Vec<_>>();
|
|
let mut terminal_target_pool_indices = Vec::new();
|
|
for (source_pool_idx, run_token) in active_runs {
|
|
let receipt_path = decommission_durable_ilm_receipt_path(&run_token, path, record.id_kind, &record.id);
|
|
let mut receipt_found = false;
|
|
for pool_idx in 0..self.pools.len() {
|
|
if pool_idx != source_pool_idx {
|
|
let found = self
|
|
.advance_durable_ilm_decommission_receipt(
|
|
pool_idx,
|
|
&receipt_path,
|
|
&record,
|
|
fleet_topology_generation.as_deref(),
|
|
terminal,
|
|
)
|
|
.await?;
|
|
receipt_found |= found;
|
|
if terminal
|
|
&& found
|
|
&& !active_source_pool_indices.contains(&pool_idx)
|
|
&& !terminal_target_pool_indices.contains(&pool_idx)
|
|
{
|
|
terminal_target_pool_indices.push(pool_idx);
|
|
}
|
|
}
|
|
}
|
|
if terminal && !receipt_found {
|
|
return Err(Error::other(format!(
|
|
"terminal durable ILM record at path `{path}` {} is retained until its decommission receipt is committed",
|
|
record.context()
|
|
)));
|
|
}
|
|
}
|
|
Ok(Some(terminal_target_pool_indices))
|
|
}
|
|
|
|
/// Resolve the exact receipt-bearing target copies on which a v6 dispatch
|
|
/// manifest may advance while a decommission reservation is active.
|
|
///
|
|
/// This is intentionally not a general capacity bypass. The target copy
|
|
/// must still be covered by the active source reservation and its durable
|
|
/// receipt, the ETag must be the caller's exact CAS generation, and the
|
|
/// replacement must be a byte-non-growing adjacent manifest checkpoint.
|
|
pub(crate) async fn decommission_durable_ilm_checkpoint_targets(
|
|
&self,
|
|
path: &str,
|
|
next_data: &[u8],
|
|
expected_etag: &str,
|
|
) -> Result<Option<Vec<DecommissionDurableIlmCheckpointTarget>>> {
|
|
let active_runs = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
let mut active_runs = Vec::new();
|
|
for (source_pool_index, pool) in pool_meta.pools.iter().enumerate() {
|
|
let Some(info) = pool
|
|
.decommission
|
|
.as_ref()
|
|
.filter(|info| info.has_decommission_state() && !info.complete)
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(start_time) = info.start_time else {
|
|
continue;
|
|
};
|
|
let reservation = info.capacity_reservation.clone().ok_or_else(|| {
|
|
decommission_capacity_blocked_error(format!(
|
|
"active decommission source pool {source_pool_index} has no reservation for durable ILM checkpoint"
|
|
))
|
|
})?;
|
|
if !reservation.active() {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"active decommission source pool {source_pool_index} has a released reservation for durable ILM checkpoint"
|
|
)));
|
|
}
|
|
active_runs.push((
|
|
source_pool_index,
|
|
decommission_durable_ilm_receipt_run_token(&pool.cmd_line, start_time),
|
|
reservation,
|
|
));
|
|
}
|
|
active_runs
|
|
};
|
|
if active_runs.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let next_record = validate_durable_ilm_record(path, next_data)?;
|
|
if next_record.namespace != TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE.name {
|
|
return Ok(None);
|
|
}
|
|
let next_fleet_topology_generation = durable_ilm_v6_topology_generation(path, next_data)?;
|
|
|
|
let mut targets = Vec::<DecommissionDurableIlmCheckpointTarget>::new();
|
|
let mut missing_source_receipts = 0usize;
|
|
let mut stale_target_etag_mismatch = false;
|
|
for (source_pool_index, run_token, reservation) in active_runs {
|
|
let receipt_path = decommission_durable_ilm_receipt_path(&run_token, path, next_record.id_kind, &next_record.id);
|
|
let mut source_receipt_found = false;
|
|
for allocation in &reservation.targets {
|
|
let receipt_data = match read_config_limited_preserve_empty(
|
|
self.pools[allocation.pool_index].clone(),
|
|
&receipt_path,
|
|
DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE,
|
|
)
|
|
.await
|
|
{
|
|
Ok(data) => data,
|
|
Err(err)
|
|
if matches!(&err, Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound)
|
|
|| is_err_object_not_found(&err)
|
|
|| is_err_version_not_found(&err) =>
|
|
{
|
|
continue;
|
|
}
|
|
Err(err) => return Err(err),
|
|
};
|
|
let receipt = DecommissionDurableIlmReceipt::decode(&receipt_data)?;
|
|
let locator = parse_decommission_durable_ilm_receipt_path(&receipt_path)?;
|
|
Self::validate_decommission_durable_ilm_receipt_locator(&receipt_path, &locator, &receipt)?;
|
|
if receipt.source_path != path
|
|
|| receipt.namespace != next_record.namespace
|
|
|| receipt.id_kind != next_record.id_kind
|
|
|| receipt.id != next_record.id
|
|
{
|
|
return Err(Error::other_with_context(
|
|
"durable ILM checkpoint receipt identity does not authorize source",
|
|
format!("path `{path}` {}", next_record.context()),
|
|
));
|
|
}
|
|
if receipt.fleet_topology_generation != next_fleet_topology_generation {
|
|
return Err(Error::other_with_context(
|
|
"durable ILM checkpoint receipt fleet topology does not authorize source",
|
|
format!("path `{path}` {}", next_record.context()),
|
|
));
|
|
}
|
|
receipt
|
|
.checkpoint
|
|
.validate_successor(&next_record.checkpoint)
|
|
.map_err(|err| {
|
|
Error::other_with_context(
|
|
"durable ILM checkpoint receipt is not a predecessor of the requested generation",
|
|
format!(
|
|
"target pool {}, path `{path}` {}; receipt checkpoint {:?}, requested checkpoint {:?}: {err}",
|
|
allocation.pool_index,
|
|
next_record.context(),
|
|
receipt.checkpoint,
|
|
next_record.checkpoint
|
|
),
|
|
)
|
|
})?;
|
|
|
|
let (target_data, metadata) = read_config_limited_preserve_empty_with_metadata(
|
|
self.pools[allocation.pool_index].clone(),
|
|
path,
|
|
TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE.max_record_size,
|
|
)
|
|
.await?;
|
|
let target_record = validate_durable_ilm_record(path, &target_data)?;
|
|
if target_record.namespace != next_record.namespace
|
|
|| target_record.id_kind != next_record.id_kind
|
|
|| target_record.id != next_record.id
|
|
{
|
|
return Err(Error::other_with_context(
|
|
"durable ILM checkpoint target identity does not match source",
|
|
format!("path `{path}` {}", next_record.context()),
|
|
));
|
|
}
|
|
let already_committed = target_data.as_slice() == next_data;
|
|
let target_etag = metadata.etag.filter(|etag| !etag.trim().is_empty()).ok_or_else(|| {
|
|
Error::other_with_context("durable ILM checkpoint target is missing an ETag", format!("path `{path}`"))
|
|
})?;
|
|
if already_committed {
|
|
receipt
|
|
.checkpoint
|
|
.validate_successor(&target_record.checkpoint)
|
|
.map_err(|err| {
|
|
Error::other_with_context(
|
|
"durable ILM checkpoint receipt is not a predecessor of the committed target generation",
|
|
format!("path `{path}` {}: {err}", next_record.context()),
|
|
)
|
|
})?;
|
|
} else {
|
|
if target_record.checkpoint != receipt.checkpoint {
|
|
return Err(Error::other_with_context(
|
|
"durable ILM checkpoint target is not the receipt generation",
|
|
format!("path `{path}` {}", next_record.context()),
|
|
));
|
|
}
|
|
target_record
|
|
.checkpoint
|
|
.validate_successor(&next_record.checkpoint)
|
|
.map_err(|err| {
|
|
Error::other_with_context(
|
|
"durable ILM checkpoint target is not a predecessor of the requested generation",
|
|
format!("path `{path}` {}: {err}", next_record.context()),
|
|
)
|
|
})?;
|
|
if next_data.len() > target_data.len() {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"durable ILM checkpoint update at `{path}` grows from {} to {} bytes",
|
|
target_data.len(),
|
|
next_data.len()
|
|
)));
|
|
}
|
|
stale_target_etag_mismatch |= target_etag != expected_etag;
|
|
}
|
|
|
|
source_receipt_found = true;
|
|
if let Some(existing) = targets
|
|
.iter_mut()
|
|
.find(|target| target.target_pool_index == allocation.pool_index)
|
|
{
|
|
if existing.already_committed != already_committed {
|
|
return Err(Error::other_with_context(
|
|
"durable ILM checkpoint target state changed during authorization",
|
|
format!("path `{path}`"),
|
|
));
|
|
}
|
|
continue;
|
|
}
|
|
let base_owner = DecommissionCapacityOwner {
|
|
source_pool_index,
|
|
operation_id: reservation.operation_id,
|
|
generation: reservation.generation,
|
|
owner_nonce: reservation.owner_nonce,
|
|
mutation_id: None,
|
|
};
|
|
let mutation_id = decommission_capacity_mutation_id(
|
|
base_owner,
|
|
RUSTFS_META_BUCKET,
|
|
path,
|
|
Some(next_record.checkpoint.content_sha256()),
|
|
false,
|
|
None,
|
|
);
|
|
targets.push(DecommissionDurableIlmCheckpointTarget {
|
|
source_pool_index,
|
|
target_pool_index: allocation.pool_index,
|
|
capacity_owner: base_owner.with_mutation_id(mutation_id),
|
|
already_committed,
|
|
target_etag: Some(target_etag),
|
|
});
|
|
}
|
|
if !source_receipt_found {
|
|
missing_source_receipts = missing_source_receipts.saturating_add(1);
|
|
}
|
|
}
|
|
|
|
if missing_source_receipts > 0 {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"durable ILM checkpoint at `{path}` is missing receipt coverage for {missing_source_receipts} active source(s)"
|
|
)));
|
|
}
|
|
if targets.is_empty() {
|
|
return Err(decommission_capacity_blocked_error(format!(
|
|
"durable ILM checkpoint at `{path}` has no receipt-bearing reservation target"
|
|
)));
|
|
}
|
|
if stale_target_etag_mismatch && !targets.iter().any(|target| target.already_committed) {
|
|
return Err(Error::PreconditionFailed);
|
|
}
|
|
// After a partial multi-target commit, an aggregate read may return
|
|
// the ETag of the already-advanced target while another authorized
|
|
// target still has the predecessor ETag. The exact committed bytes
|
|
// plus every target's receipt/checkpoint proof authorize repairing
|
|
// that predecessor with its own target-local CAS. Without an exact
|
|
// committed target, retain the caller-ETag requirement above.
|
|
targets.sort_unstable_by_key(|target| target.target_pool_index);
|
|
Ok(Some(targets))
|
|
}
|
|
|
|
pub(crate) async fn record_durable_ilm_decommission_progress(&self, path: &str, data: &[u8]) -> Result<()> {
|
|
self.advance_durable_ilm_decommission_receipts(path, data, false)
|
|
.await
|
|
.map(|_| ())
|
|
}
|
|
|
|
pub(crate) async fn record_durable_ilm_decommission_terminal(&self, path: &str, data: &[u8]) -> Result<()> {
|
|
self.record_durable_ilm_decommission_terminal_target_pools(path, data)
|
|
.await
|
|
.map(|_| ())
|
|
}
|
|
|
|
/// Record terminal proof and return its non-source receipt pools for targeted cleanup.
|
|
pub(crate) async fn record_durable_ilm_decommission_terminal_target_pools(
|
|
&self,
|
|
path: &str,
|
|
data: &[u8],
|
|
) -> Result<Option<Vec<usize>>> {
|
|
self.advance_durable_ilm_decommission_receipts(path, data, true).await
|
|
}
|
|
|
|
/// Return true only when `data` is the exact copy still owned by an active
|
|
/// decommission source and a target-side terminal receipt authorizes that
|
|
/// source's later verified cleanup. Lifecycle recovery may then regard the
|
|
/// logical record as terminal without deleting the source checkpoint.
|
|
pub(crate) async fn durable_ilm_terminal_receipt_covers_active_source(&self, path: &str, data: &[u8]) -> Result<bool> {
|
|
let namespace = classify_durable_ilm_record(path)?
|
|
.ok_or_else(|| Error::other_with_context("path is not a durable ILM record", format!("path `{path}`")))?;
|
|
let source_record = validate_durable_ilm_record(path, data)?;
|
|
let active_runs = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
pool_meta
|
|
.pools
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(source_pool_index, pool)| {
|
|
pool.decommission
|
|
.as_ref()
|
|
.filter(|info| info.has_decommission_state() && !info.complete)
|
|
.and_then(|info| info.start_time)
|
|
.map(|start_time| {
|
|
(source_pool_index, decommission_durable_ilm_receipt_run_token(&pool.cmd_line, start_time))
|
|
})
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
let mut covered_active_source = false;
|
|
for (source_pool_index, run_token) in active_runs {
|
|
let source_data =
|
|
match read_config_limited_preserve_empty(self.pools[source_pool_index].clone(), path, namespace.max_record_size)
|
|
.await
|
|
{
|
|
Ok(source_data) => source_data,
|
|
Err(err)
|
|
if matches!(&err, Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound)
|
|
|| is_err_object_not_found(&err)
|
|
|| is_err_version_not_found(&err) =>
|
|
{
|
|
continue;
|
|
}
|
|
Err(err) => return Err(err),
|
|
};
|
|
if source_data.as_slice() != data {
|
|
continue;
|
|
}
|
|
if self
|
|
.load_decommission_durable_ilm_terminal_receipt_for_run(source_pool_index, path, &source_record, &run_token)
|
|
.await?
|
|
.is_some()
|
|
{
|
|
covered_active_source = true;
|
|
} else {
|
|
// Every active source that still stores this exact generation
|
|
// needs complete terminal receipt coverage. One covered source
|
|
// cannot authorize lifecycle recovery to skip another.
|
|
return Ok(false);
|
|
}
|
|
}
|
|
Ok(covered_active_source)
|
|
}
|
|
|
|
async fn cleanup_decommission_durable_ilm_receipts(&self, source_pool_idx: usize) -> Result<()> {
|
|
for (pool_idx, receipt_path) in self.list_decommission_durable_ilm_receipts(source_pool_idx).await? {
|
|
match delete_config(self.pools[pool_idx].clone(), &receipt_path).await {
|
|
Ok(()) | Err(Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound) => {}
|
|
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"failed to clean durable ILM decommission receipt `{receipt_path}` from pool {pool_idx}: {err}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
for receipt_path in self.list_decommission_durable_ilm_manifest_receipts(source_pool_idx).await? {
|
|
match delete_config(self.pools[source_pool_idx].clone(), &receipt_path).await {
|
|
Ok(()) | Err(Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound) => {}
|
|
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"failed to clean durable ILM expected manifest receipt `{receipt_path}` from source pool {source_pool_idx}: {err}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?;
|
|
let manifest_path = decommission_durable_ilm_manifest_path(&run_token);
|
|
match delete_config(self.pools[source_pool_idx].clone(), &manifest_path).await {
|
|
Ok(()) | Err(Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound) => {}
|
|
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"failed to clean durable ILM expected manifest `{manifest_path}` from source pool {source_pool_idx}: {err}"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
async fn verify_and_cleanup_decommissioned_durable_ilm_record(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
source_set: Arc<SetDisks>,
|
|
path: &str,
|
|
) -> Result<()> {
|
|
let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?;
|
|
self.verify_and_cleanup_decommissioned_durable_ilm_record_for_run(source_pool_idx, source_set, path, &run_token)
|
|
.await
|
|
}
|
|
|
|
async fn verify_and_cleanup_decommissioned_durable_ilm_record_for_run(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
source_set: Arc<SetDisks>,
|
|
path: &str,
|
|
run_token: &str,
|
|
) -> Result<()> {
|
|
let namespace = classify_durable_ilm_record(path)?
|
|
.ok_or_else(|| Error::other(format!("path `{path}` is not a durable ILM record")))?;
|
|
let source_versions = source_set
|
|
.load_file_info_versions_exact(RUSTFS_META_BUCKET, path)
|
|
.await
|
|
.map_err(|err| Error::other(format!("failed to load source durable ILM versions at path `{path}`: {err}")))?
|
|
.ok_or_else(|| Error::other(format!("source durable ILM record is missing at path `{path}`")))?;
|
|
let source = read_config_limited_preserve_empty(source_set.clone(), path, namespace.max_record_size)
|
|
.await
|
|
.map_err(|err| Error::other(format!("failed to read source durable ILM record at path `{path}`: {err}")))?;
|
|
let source_record = validate_durable_ilm_record(path, &source)
|
|
.map_err(|err| Error::other(format!("source durable ILM record is invalid at path `{path}`: {err}")))?;
|
|
let source_fleet_topology_generation = durable_ilm_v6_topology_generation(path, &source)?;
|
|
let target = self
|
|
.load_decommissioned_durable_ilm_target(source_pool_idx, path, namespace.max_record_size, &source_record.context())
|
|
.await?;
|
|
let manifest_receipt = if let Some((target_pool_idx, target)) = target {
|
|
let target_record = validate_decommission_durable_ilm_copy(path, &source_record, &target)?;
|
|
let target_fleet_topology_generation = durable_ilm_v6_topology_generation(path, &target)?;
|
|
if target_fleet_topology_generation != source_fleet_topology_generation {
|
|
return Err(Error::other_with_context(
|
|
"target durable ILM fleet topology generation differs from source",
|
|
format!("path `{path}` {}", source_record.context()),
|
|
));
|
|
}
|
|
let receipt = DecommissionDurableIlmReceipt::new(path, &target_record, target_fleet_topology_generation);
|
|
self.persist_decommission_durable_ilm_receipt_for_run(target_pool_idx, &receipt, run_token)
|
|
.await?;
|
|
receipt
|
|
} else {
|
|
self.load_decommission_durable_ilm_terminal_receipt_for_run(source_pool_idx, path, &source_record, run_token)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
Error::other(format!(
|
|
"target durable ILM record is missing at path `{path}` {} without a matching terminal receipt",
|
|
source_record.context()
|
|
))
|
|
})?
|
|
};
|
|
if manifest_receipt.fleet_topology_generation != source_fleet_topology_generation {
|
|
return Err(Error::other_with_context(
|
|
"terminal durable ILM receipt fleet topology generation does not cover source",
|
|
format!("path `{path}` {}", source_record.context()),
|
|
));
|
|
}
|
|
let fleet_proof = if let Some(expected_generation) = source_fleet_topology_generation.as_deref() {
|
|
let proof = acquire_tier_delete_journal_fleet_proof()
|
|
.ok_or_else(|| Error::other("tier delete journal v6 fleet capability is unavailable for source cleanup"))?;
|
|
if tier_delete_journal_topology_generation(&proof) != expected_generation
|
|
|| !tier_delete_journal_fleet_proof_matches(&proof)
|
|
{
|
|
return Err(Error::other("tier delete journal v6 fleet generation changed before source cleanup"));
|
|
}
|
|
Some(proof)
|
|
} else {
|
|
None
|
|
};
|
|
self.persist_decommission_durable_ilm_receipt_for_run(source_pool_idx, &manifest_receipt, run_token)
|
|
.await?;
|
|
|
|
if fleet_proof
|
|
.as_ref()
|
|
.is_some_and(|proof| !tier_delete_journal_fleet_proof_matches(proof))
|
|
{
|
|
return Err(Error::other("tier delete journal v6 fleet proof changed before source cleanup"));
|
|
}
|
|
|
|
let cleanup_result = data_movement::cleanup_source_entry_if_unchanged(
|
|
source_set,
|
|
RUSTFS_META_BUCKET,
|
|
path,
|
|
&source_versions,
|
|
&[],
|
|
data_movement::SourceCleanupBucketFence::default(),
|
|
"decommission durable ILM final sweep",
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
Error::other(format!(
|
|
"source durable ILM cleanup failed at path `{path}` {}: {err}",
|
|
source_record.context()
|
|
))
|
|
});
|
|
let cleanup_result = resolve_decommission_entry_cleanup_delete_result(cleanup_result, RUSTFS_META_BUCKET, path);
|
|
if fleet_proof
|
|
.as_ref()
|
|
.is_some_and(|proof| !tier_delete_journal_fleet_proof_matches(proof))
|
|
{
|
|
return Err(Error::other(
|
|
"tier delete journal v6 fleet proof changed during source cleanup; exact source outcome requires verification",
|
|
));
|
|
}
|
|
cleanup_result
|
|
}
|
|
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
pub(crate) async fn verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
source_set: Arc<SetDisks>,
|
|
path: &str,
|
|
) -> Result<()> {
|
|
self.verify_and_cleanup_decommissioned_durable_ilm_record(source_pool_idx, source_set, path)
|
|
.await
|
|
}
|
|
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
pub(crate) async fn decommission_durable_ilm_receipt_count_for_test(&self, source_pool_idx: usize) -> Result<usize> {
|
|
Ok(self.list_decommission_durable_ilm_receipts(source_pool_idx).await?.len())
|
|
}
|
|
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
pub(crate) async fn decommission_durable_ilm_receipt_paths_for_test(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
) -> Result<Vec<(usize, String)>> {
|
|
self.list_decommission_durable_ilm_receipts(source_pool_idx).await
|
|
}
|
|
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
pub(crate) async fn persist_decommission_durable_ilm_receipt_for_test(
|
|
&self,
|
|
source_pool_idx: usize,
|
|
target_pool_idx: usize,
|
|
source_path: &str,
|
|
record: &ValidatedDurableIlmRecord,
|
|
terminal: bool,
|
|
) -> Result<String> {
|
|
let fleet_topology_generation = match read_config_limited_preserve_empty(
|
|
self.pools[target_pool_idx].clone(),
|
|
source_path,
|
|
classify_durable_ilm_record(source_path)?
|
|
.ok_or_else(|| Error::other_with_context("path is not a durable ILM record", format!("path `{source_path}`")))?
|
|
.max_record_size,
|
|
)
|
|
.await
|
|
{
|
|
Ok(target_data) => durable_ilm_v6_topology_generation(source_path, &target_data)?,
|
|
Err(err)
|
|
if matches!(&err, Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound)
|
|
|| is_err_object_not_found(&err)
|
|
|| is_err_version_not_found(&err) =>
|
|
{
|
|
None
|
|
}
|
|
Err(err) => return Err(err),
|
|
};
|
|
let mut receipt = DecommissionDurableIlmReceipt::new(source_path, record, fleet_topology_generation);
|
|
if terminal {
|
|
receipt.terminal_checkpoint = Some(record.checkpoint.clone());
|
|
}
|
|
self.persist_decommission_durable_ilm_receipt(source_pool_idx, target_pool_idx, &receipt)
|
|
.await?;
|
|
let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?;
|
|
Ok(decommission_durable_ilm_receipt_path(&run_token, source_path, record.id_kind, &record.id))
|
|
}
|
|
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
pub(crate) async fn persist_decommission_durable_ilm_manifest_for_test(&self, source_pool_idx: usize) -> Result<()> {
|
|
self.persist_decommission_durable_ilm_manifest(source_pool_idx).await
|
|
}
|
|
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
pub(crate) async fn cleanup_decommission_durable_ilm_receipts_for_test(&self, source_pool_idx: usize) -> Result<()> {
|
|
self.cleanup_decommission_durable_ilm_receipts(source_pool_idx).await
|
|
}
|
|
|
|
async fn check_after_decommission(
|
|
self: &Arc<Self>,
|
|
idx: usize,
|
|
rx: &CancellationToken,
|
|
generation: OffsetDateTime,
|
|
) -> Result<Vec<DecommissionUnresolvedEntry>> {
|
|
let run_token = self.durable_ilm_receipt_run_token_for_generation(idx, generation).await?;
|
|
let operation_gate = self.ctx.data_movement_operation_gate();
|
|
self.run_guarded_decommission_side_effect(rx, &operation_gate, || {
|
|
self.check_after_decommission_unfenced(idx, generation, run_token)
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn check_after_decommission_unfenced(
|
|
self: &Arc<Self>,
|
|
idx: usize,
|
|
generation: OffsetDateTime,
|
|
run_token: String,
|
|
) -> Result<Vec<DecommissionUnresolvedEntry>> {
|
|
let unresolved_entries = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
ensure_decommission_generation(&pool_meta, idx, generation)?;
|
|
let info = pool_meta.pools[idx]
|
|
.decommission
|
|
.as_ref()
|
|
.ok_or_else(|| decommission_metadata_not_initialized_error("verify unresolved decommission entries"))?;
|
|
if info
|
|
.unresolved_entries
|
|
.iter()
|
|
.any(|entry| entry.pool_index != idx || entry.source_generation != generation)
|
|
{
|
|
return Err(Error::other(format!(
|
|
"failed to verify decommission for pool {idx}: unresolved listing ledger contains a different pool or generation"
|
|
)));
|
|
}
|
|
info.unresolved_entries.clone()
|
|
};
|
|
let unresolved_entries_by_identity = Arc::new(
|
|
unresolved_entries
|
|
.iter()
|
|
.map(|entry| (decommission_unresolved_entry_identity(entry), entry.clone()))
|
|
.collect::<HashMap<_, _>>(),
|
|
);
|
|
let resolved_unresolved_entries = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
|
let buckets = self.get_buckets_to_decommission().await?;
|
|
let pool = self.pools[idx].clone();
|
|
|
|
self.ensure_decommission_multipart_uploads_drained(idx, &pool, &buckets)
|
|
.await?;
|
|
|
|
for (set_index, set) in pool.disk_set.iter().enumerate() {
|
|
let require_all_disks = unresolved_entries.iter().any(|entry| entry.set_index == set_index);
|
|
for bucket_info in &buckets {
|
|
let mut lifecycle_config = None;
|
|
let mut object_lock_config = None;
|
|
let mut replication_configured = false;
|
|
if bucket_info.name != RUSTFS_META_BUCKET {
|
|
let expiry_configs = get_expiry_configs(self, &bucket_info.name).await?;
|
|
lifecycle_config = expiry_configs.lifecycle.map(|config| (*config).clone());
|
|
object_lock_config = expiry_configs.object_lock.map(|config| (*config).clone());
|
|
replication_configured = resolve_decommission_optional_bucket_config_result(
|
|
&bucket_info.name,
|
|
"replication",
|
|
metadata_sys::get_replication_config_in(&self.ctx, &bucket_info.name).await,
|
|
)?
|
|
.is_some();
|
|
}
|
|
|
|
let versions_found = Arc::new(AtomicUsize::new(0));
|
|
let entry_error = Arc::new(tokio::sync::Mutex::new(None::<Error>));
|
|
let first_remaining_path = Arc::new(tokio::sync::Mutex::new(None::<String>));
|
|
let callback_rx = CancellationToken::new();
|
|
let versions_found_cb = versions_found.clone();
|
|
let entry_error_cb = entry_error.clone();
|
|
let first_remaining_path_cb = first_remaining_path.clone();
|
|
let bucket_name = bucket_info.name.clone();
|
|
let lifecycle_config_cb = lifecycle_config.clone();
|
|
let object_lock_config_cb = object_lock_config.clone();
|
|
let replication_configured_cb = replication_configured;
|
|
let store = Arc::clone(self);
|
|
let source_set = set.clone();
|
|
let callback_rx_cb = callback_rx.clone();
|
|
let unresolved_entries_by_identity_cb = unresolved_entries_by_identity.clone();
|
|
let resolved_unresolved_entries_cb = resolved_unresolved_entries.clone();
|
|
let run_token_cb = run_token.clone();
|
|
|
|
let callback: ListCallback = Arc::new(move |entry: MetaCacheEntry| {
|
|
let versions_found = versions_found_cb.clone();
|
|
let entry_error = entry_error_cb.clone();
|
|
let first_remaining_path = first_remaining_path_cb.clone();
|
|
let bucket_name = bucket_name.clone();
|
|
let lifecycle_config = lifecycle_config_cb.clone();
|
|
let object_lock_config = object_lock_config_cb.clone();
|
|
let replication_configured = replication_configured_cb;
|
|
let store = Arc::clone(&store);
|
|
let source_set = source_set.clone();
|
|
let callback_rx = callback_rx_cb.clone();
|
|
let unresolved_entries_by_identity = unresolved_entries_by_identity_cb.clone();
|
|
let resolved_unresolved_entries = resolved_unresolved_entries_cb.clone();
|
|
let run_token = run_token_cb.clone();
|
|
Box::pin(async move {
|
|
if callback_rx.is_cancelled() {
|
|
return;
|
|
}
|
|
|
|
if !entry.is_object() {
|
|
return;
|
|
}
|
|
|
|
if bucket_name == RUSTFS_META_BUCKET && entry.name.contains(DATA_USAGE_CACHE_NAME) {
|
|
return;
|
|
}
|
|
|
|
let durable_ilm_record = if bucket_name == RUSTFS_META_BUCKET {
|
|
match classify_durable_ilm_record(&entry.name) {
|
|
Ok(record) => record,
|
|
Err(err) => {
|
|
let mut first_err = entry_error.lock().await;
|
|
if first_err.is_none() {
|
|
*first_err = Some(with_decommission_entry_context(
|
|
"check_after_decommission.durable_ilm_namespace",
|
|
&bucket_name,
|
|
&entry.name,
|
|
err,
|
|
));
|
|
callback_rx.cancel();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if durable_ilm_record.is_some() {
|
|
if let Err(err) = store
|
|
.verify_and_cleanup_decommissioned_durable_ilm_record_for_run(
|
|
idx,
|
|
source_set,
|
|
&entry.name,
|
|
&run_token,
|
|
)
|
|
.await
|
|
{
|
|
let mut first_err = entry_error.lock().await;
|
|
if first_err.is_none() {
|
|
*first_err = Some(err);
|
|
callback_rx.cancel();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
let mut fivs = match load_decommission_entry_exact_versions(
|
|
&source_set,
|
|
&entry,
|
|
&bucket_name,
|
|
"check_after_decommission.file_info_versions",
|
|
)
|
|
.await
|
|
{
|
|
Ok(fivs) => fivs,
|
|
Err(err) => {
|
|
let mut first_err = entry_error.lock().await;
|
|
if first_err.is_none() {
|
|
*first_err = Some(err);
|
|
callback_rx.cancel();
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
|
|
fivs.versions
|
|
.sort_by_key(|version| (version.mod_time.is_none(), std::cmp::Reverse(version.mod_time)));
|
|
|
|
let mut remaining = 0;
|
|
let mut expired = 0;
|
|
for version in fivs.versions.iter().chain(fivs.free_versions.iter()) {
|
|
if version.tier_free_version() {
|
|
remaining += 1;
|
|
debug!(
|
|
event = EVENT_DECOMMISSION_ENTRY,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
pool_index = idx,
|
|
bucket = %bucket_name,
|
|
object = %entry.name,
|
|
version_id = ?version.version_id,
|
|
reason = DECOMMISSION_FREE_VERSION_SWEEP_REASON,
|
|
state = "free_version_retained",
|
|
"Decommission final sweep retained a free version"
|
|
);
|
|
continue;
|
|
}
|
|
let skip_lifecycle = match should_skip_lifecycle_for_data_movement(
|
|
Arc::clone(&store),
|
|
&bucket_name,
|
|
version,
|
|
lifecycle_config.as_ref(),
|
|
object_lock_config.as_ref(),
|
|
false,
|
|
&LcEventSrc::Decom,
|
|
None,
|
|
)
|
|
.await
|
|
{
|
|
Ok(skip_lifecycle) => skip_lifecycle,
|
|
Err(err) => {
|
|
let mut first_err = entry_error.lock().await;
|
|
if first_err.is_none() {
|
|
*first_err = Some(err);
|
|
callback_rx.cancel();
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
if skip_lifecycle {
|
|
expired += 1;
|
|
continue;
|
|
}
|
|
let remaining_versions = decommission_remaining_version_count(&fivs.versions, expired);
|
|
if should_skip_decommission_delete_marker(version, remaining_versions, replication_configured) {
|
|
continue;
|
|
}
|
|
remaining += 1;
|
|
}
|
|
|
|
if remaining > 0 {
|
|
let mut first_path = first_remaining_path.lock().await;
|
|
if first_path.is_none() {
|
|
*first_path = Some(format!("{bucket_name}/{}", entry.name));
|
|
}
|
|
} else {
|
|
let identity = (set_index, bucket_name.clone(), entry.name.clone());
|
|
if let Some(unresolved_entry) = unresolved_entries_by_identity.get(&identity) {
|
|
let mut resolved = resolved_unresolved_entries.lock().await;
|
|
if !resolved.contains(unresolved_entry) {
|
|
resolved.push(unresolved_entry.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
versions_found.fetch_add(remaining, Ordering::Relaxed);
|
|
})
|
|
});
|
|
|
|
let list_result = set
|
|
.list_objects_to_decommission(
|
|
self.clone(),
|
|
callback_rx,
|
|
bucket_info.clone(),
|
|
callback,
|
|
entry_error.clone(),
|
|
idx,
|
|
set_index,
|
|
generation,
|
|
require_all_disks,
|
|
)
|
|
.await;
|
|
let entry_error = entry_error.lock().await.clone();
|
|
resolve_decommission_check_after_list_result(list_result, entry_error)?;
|
|
|
|
let versions_found = versions_found.load(Ordering::Relaxed);
|
|
if versions_found > 0 {
|
|
let first_remaining_path = first_remaining_path
|
|
.lock()
|
|
.await
|
|
.clone()
|
|
.unwrap_or_else(|| format!("{}/<unknown>", bucket_info.name));
|
|
return Err(Error::other(format!(
|
|
"at least {versions_found} object(s)/version(s) were found in bucket `{}` after decommissioning; first remaining path `{first_remaining_path}`",
|
|
bucket_info.name,
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut verified_unresolved_entries = resolved_unresolved_entries.lock().await.clone();
|
|
for entry in &unresolved_entries {
|
|
let set = pool.disk_set.get(entry.set_index).ok_or_else(|| {
|
|
Error::other(format!(
|
|
"failed to verify decommission for pool {idx}: unresolved listing entry references missing set {}",
|
|
entry.set_index
|
|
))
|
|
})?;
|
|
if set.decommission_unresolved_entry_absent_on_all_disks(idx, entry).await?
|
|
&& !verified_unresolved_entries.contains(entry)
|
|
{
|
|
verified_unresolved_entries.push(entry.clone());
|
|
}
|
|
if !verified_unresolved_entries.contains(entry) {
|
|
return Err(Error::other(format!(
|
|
"failed to verify decommission for pool {idx}: unresolved listing entry {}/{} in set {} was neither re-observed as resolved nor absent on every source disk",
|
|
entry.bucket, entry.object, entry.set_index
|
|
)));
|
|
}
|
|
}
|
|
|
|
self.persist_decommission_durable_ilm_manifest_for_run(idx, &run_token)
|
|
.await?;
|
|
self.verify_decommission_durable_ilm_receipts_for_run(idx, &run_token).await?;
|
|
|
|
Ok(verified_unresolved_entries)
|
|
}
|
|
|
|
async fn ensure_decommission_multipart_uploads_drained(
|
|
&self,
|
|
idx: usize,
|
|
pool: &Sets,
|
|
buckets: &[DecomBucketInfo],
|
|
) -> Result<()> {
|
|
let mut bucket_names = buckets
|
|
.iter()
|
|
.filter(|bucket| bucket.name != RUSTFS_META_BUCKET)
|
|
.map(|bucket| bucket.name.as_str())
|
|
.collect::<Vec<_>>();
|
|
bucket_names.sort_unstable();
|
|
bucket_names.dedup();
|
|
|
|
// Take one bucket fence at a time so cross-bucket COPY cannot form an
|
|
// ABBA cycle. Suspension prevents new source uploads after each fence.
|
|
for bucket in bucket_names {
|
|
let lifecycle_guard = self.acquire_bucket_lifecycle_write_lock(bucket).await?;
|
|
if lifecycle_guard.is_lock_lost() {
|
|
return Err(Error::other(format!(
|
|
"decommission multipart drain lost the bucket lifecycle fence for `{bucket}`"
|
|
)));
|
|
}
|
|
for set in &pool.disk_set {
|
|
if let Some(upload_path) = set.first_multipart_upload_path_for_decommission(bucket).await? {
|
|
return Err(Error::other(format!(
|
|
"pool {idx} still contains multipart upload `{upload_path}` for bucket `{bucket}`; resolve it before retrying decommission"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn ensure_decommission_multipart_uploads_drained_for_test(self: &Arc<Self>, idx: usize) -> Result<()> {
|
|
let buckets = self.get_buckets_to_decommission().await?;
|
|
let pool = self.pools[idx].clone();
|
|
self.ensure_decommission_multipart_uploads_drained(idx, pool.as_ref(), &buckets)
|
|
.await
|
|
}
|
|
#[cfg(all(test, feature = "test-util"))]
|
|
pub(crate) async fn check_after_decommission_for_test(self: &Arc<Self>, idx: usize) -> Result<()> {
|
|
let generation = self.active_decommission_generation(idx).await?;
|
|
self.check_after_decommission(idx, &CancellationToken::new(), generation)
|
|
.await
|
|
.map(|_| ())
|
|
}
|
|
|
|
#[tracing::instrument(skip(self, rd))]
|
|
async fn decommission_object(
|
|
self: Arc<Self>,
|
|
pool_idx: usize,
|
|
bucket: String,
|
|
rd: GetObjectReader,
|
|
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
|
capacity_owner: Option<DecommissionCapacityOwner>,
|
|
) -> Result<()> {
|
|
warn!("decommission_object: start {} {}", &bucket, &rd.object_info.name);
|
|
let object_name = rd.object_info.name.clone();
|
|
let mut migration = tokio::task::JoinSet::new();
|
|
migration.spawn(data_movement::migrate_decommission_object(
|
|
self,
|
|
pool_idx,
|
|
bucket.clone(),
|
|
rd,
|
|
expected_bucket_incarnation_id,
|
|
"decommission_object",
|
|
capacity_owner,
|
|
));
|
|
let result = migration
|
|
.join_next()
|
|
.await
|
|
.ok_or_else(|| Error::other("decommission migration task was not started"))?
|
|
.map_err(|err| Error::other(format!("decommission migration task join error: {err}")))?;
|
|
if result.is_ok() {
|
|
warn!("decommission_object: migrated {} {}", &bucket, &object_name);
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn persist_v3_pool_meta_for_test(store: &Arc<ECStore>) {
|
|
let mut meta = PoolMeta::default();
|
|
meta.load_no_lock_from_replicas(store.pools.clone())
|
|
.await
|
|
.expect("the baseline pool metadata should be readable before V3 migration");
|
|
meta.version = POOL_META_GENERATION_VERSION;
|
|
let mut write_state = store.pool_meta_save_gate.lock().await.clone();
|
|
temp_env::async_with_vars(
|
|
[
|
|
(rustfs_config::ENV_POOL_META_V3_WRITE, Some("true")),
|
|
(rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED, Some("true")),
|
|
],
|
|
async {
|
|
meta.save_no_lock_observing(store.pools.clone(), &mut write_state)
|
|
.await
|
|
.expect("the baseline metadata should be upgraded to a durable V3 replica");
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let mut loaded = PoolMeta::default();
|
|
loaded
|
|
.load_no_lock_from_replicas(store.pools.clone())
|
|
.await
|
|
.expect("the upgraded V3 pool metadata should be readable");
|
|
assert_eq!(loaded.version, POOL_META_GENERATION_VERSION);
|
|
store.pool_meta.write().await.version = POOL_META_GENERATION_VERSION;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::items_after_test_module)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::bucket::replication::{ReplicationState, ReplicationStatusType};
|
|
use crate::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause};
|
|
use crate::storage_api_contracts::multipart::MultipartOperations as _;
|
|
use serde::Serialize;
|
|
|
|
#[test]
|
|
fn pool_activation_fleet_proof_error_classifier_matches_only_retryable_proof_failures() {
|
|
assert!(is_pool_activation_fleet_proof_error(&Error::other(POOL_ACTIVATION_FLEET_PROOF_REQUIRED)));
|
|
assert!(is_pool_activation_fleet_proof_error(&Error::other(POOL_ACTIVATION_FLEET_PROOF_EXPIRED)));
|
|
let wrapped = format!("rebalance meta save failed during start_rebalance: {POOL_ACTIVATION_FLEET_PROOF_EXPIRED}");
|
|
assert!(is_pool_activation_fleet_proof_error(&Error::other(wrapped)));
|
|
assert!(!is_pool_activation_fleet_proof_error(&Error::ConfigNotFound));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn decommission_v1_start_preflights_reject_before_metadata_writes() {
|
|
let (_temp_dirs, store, _other_store) = crate::services::rebalance::test_two_pool_stores(None).await;
|
|
let mut v1_meta = store.pool_meta.read().await.clone();
|
|
v1_meta.version = POOL_META_V1_VERSION;
|
|
let v1_data = pool_meta_v1_replica_test_data(&v1_meta);
|
|
for pool in &store.pools {
|
|
save_config_with_opts(
|
|
pool.clone(),
|
|
POOL_META_NAME,
|
|
v1_data.clone(),
|
|
&ObjectOptions {
|
|
max_parity: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("the V1 baseline should be persisted to every pool");
|
|
}
|
|
let baseline = load_pool_meta_replicas(store.pools.clone(), true)
|
|
.await
|
|
.expect("baseline pool metadata should be readable");
|
|
assert_eq!(baseline.meta.version, POOL_META_V1_VERSION);
|
|
*store.pool_meta.write().await = baseline.meta.clone();
|
|
let start_probe = PoolActivationStartProbe::install(PoolActivationStartKind::Decommission);
|
|
let err = store
|
|
.start_decommission(vec![0])
|
|
.await
|
|
.expect_err("the initial V1 start preflight must reject before side effects");
|
|
assert!(matches!(err, Error::InvalidArgument(..)));
|
|
assert!(
|
|
!start_probe.preflight_side_effect_was_attempted(),
|
|
"V1 rejection must precede bucket listing, healing, and metadata-bucket creation"
|
|
);
|
|
assert!(
|
|
!start_probe.activation_was_attempted(),
|
|
"V1 rejection must not enter the authoritative activation save"
|
|
);
|
|
let after_early_rejection = load_pool_meta_replicas(store.pools.clone(), true)
|
|
.await
|
|
.expect("early rejection must leave durable pool metadata readable");
|
|
assert_eq!(after_early_rejection.canonical, baseline.canonical);
|
|
assert!(
|
|
store
|
|
.pool_meta
|
|
.read()
|
|
.await
|
|
.pools
|
|
.iter()
|
|
.all(|pool| pool.decommission.is_none())
|
|
);
|
|
assert!(store.decommission_cancelers.read().await.iter().all(Option::is_none));
|
|
store
|
|
.ensure_pool_meta_side_effects_safe("V1 start preflight")
|
|
.await
|
|
.expect("a deterministic start rejection must not latch recovery");
|
|
drop(start_probe);
|
|
|
|
let err = store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
.expect_err("the authoritative V1 start preflight must reject before saving");
|
|
assert!(matches!(err, Error::InvalidArgument(..)));
|
|
|
|
let after_authoritative_rejection = load_pool_meta_replicas(store.pools.clone(), true)
|
|
.await
|
|
.expect("authoritative rejection must leave durable pool metadata readable");
|
|
assert_eq!(after_authoritative_rejection.canonical, baseline.canonical);
|
|
assert!(
|
|
after_authoritative_rejection
|
|
.meta
|
|
.pools
|
|
.iter()
|
|
.all(|pool| pool.decommission.is_none())
|
|
);
|
|
assert!(
|
|
store
|
|
.pool_meta
|
|
.read()
|
|
.await
|
|
.pools
|
|
.iter()
|
|
.all(|pool| pool.decommission.is_none())
|
|
);
|
|
assert!(store.decommission_cancelers.read().await.iter().all(Option::is_none));
|
|
store
|
|
.ensure_pool_meta_side_effects_safe("authoritative V1 start preflight")
|
|
.await
|
|
.expect("an authoritative capability rejection must not latch recovery");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn decommission_activation_fence_loss_after_durable_save_blocks_publication() {
|
|
let (_temp_dirs, store, _other_store) = crate::services::rebalance::test_two_pool_stores(None).await;
|
|
crate::services::rebalance::promote_test_pool_meta_to_v2(&store).await;
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
set_decommission_capacity_info_overrides_for_test(
|
|
store.id,
|
|
vec![vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 60, 60, 0),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, 100, 100),
|
|
]],
|
|
);
|
|
let barrier = PoolActivationDurableSaveBarrier::install(&store.pools[0]);
|
|
let start_store = Arc::clone(&store);
|
|
let mut start_task = tokio::spawn(async move {
|
|
start_store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
});
|
|
|
|
tokio::select! {
|
|
result = &mut start_task => panic!("activation finished before the durable-save barrier: {result:?}"),
|
|
() = barrier.wait_until_paused() => {}
|
|
}
|
|
barrier.release_after_fence_loss();
|
|
let err = tokio::time::timeout(std::time::Duration::from_secs(30), start_task)
|
|
.await
|
|
.expect("decommission activation should stop after losing its fence")
|
|
.expect("decommission activation task should not panic")
|
|
.expect_err("post-durable-save fence loss must reject in-memory publication");
|
|
assert!(err.to_string().contains("activation lock lost"));
|
|
|
|
let local = store.pool_meta.read().await;
|
|
assert!(
|
|
!pool_meta_has_active_decommission(&local),
|
|
"the activation must not publish after its durable fence is lost"
|
|
);
|
|
drop(local);
|
|
store
|
|
.ensure_pool_meta_side_effects_safe("post-durable-save activation fence loss")
|
|
.await
|
|
.expect_err("the undisarmed transaction must latch the sticky pool metadata gate");
|
|
|
|
let mut persisted = PoolMeta::default();
|
|
persisted
|
|
.load_no_lock_from_replicas(vec![store.pools[0].clone()])
|
|
.await
|
|
.expect("the durable replica should remain readable for recovery");
|
|
assert!(
|
|
pool_meta_has_active_decommission(&persisted),
|
|
"the test must lose the fence only after one durable replica commit"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn decommission_activation_adopts_canonical_commit_after_replica_failure() {
|
|
let (_temp_dirs, store, _other_store) = crate::services::rebalance::test_two_pool_stores(None).await;
|
|
crate::services::rebalance::promote_test_pool_meta_to_v2(&store).await;
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
set_decommission_capacity_info_overrides_for_test(
|
|
store.id,
|
|
vec![vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 60, 60, 0),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, 100, 100),
|
|
]],
|
|
);
|
|
let barrier = PoolActivationDurableSaveBarrier::install(&store.pools[0]);
|
|
let start_store = Arc::clone(&store);
|
|
let mut start_task = tokio::spawn(async move {
|
|
start_store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
});
|
|
|
|
tokio::select! {
|
|
result = &mut start_task => panic!("activation finished before the durable-save barrier: {result:?}"),
|
|
() = barrier.wait_until_paused() => {}
|
|
}
|
|
let mut replica_disks = Vec::new();
|
|
for set in &store.pools[1].disk_set {
|
|
let mut disks = set.disks.write().await;
|
|
let saved = std::mem::take(&mut *disks);
|
|
*disks = vec![None; saved.len()];
|
|
replica_disks.push(saved);
|
|
}
|
|
barrier.release_without_fence_loss();
|
|
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), start_task)
|
|
.await
|
|
.expect("decommission activation should finish after its canonical commit")
|
|
.expect("decommission activation task should not panic")
|
|
.expect("a replica save failure must not report the committed activation as failed");
|
|
|
|
for (set, disks) in store.pools[1].disk_set.iter().zip(replica_disks) {
|
|
*set.disks.write().await = disks;
|
|
}
|
|
|
|
let local = store.pool_meta.read().await;
|
|
assert!(pool_meta_has_active_decommission(&local));
|
|
drop(local);
|
|
|
|
let mut canonical = PoolMeta::default();
|
|
canonical
|
|
.load_no_lock_from_replicas(vec![store.pools[0].clone()])
|
|
.await
|
|
.expect("the canonical committed decommission metadata should remain readable");
|
|
assert!(pool_meta_has_active_decommission(&canonical));
|
|
|
|
let mut replica = PoolMeta::default();
|
|
replica
|
|
.load_no_lock_from_replicas(vec![store.pools[1].clone()])
|
|
.await
|
|
.expect("the stale replica metadata should remain readable after disks recover");
|
|
assert!(!pool_meta_has_active_decommission(&replica));
|
|
|
|
let mut reloaded = PoolMeta::default();
|
|
let replica_state = reloaded
|
|
.load_no_lock_from_replicas(store.pools.clone())
|
|
.await
|
|
.expect("the canonical commit should remain reloadable after a replica recovers stale");
|
|
assert!(pool_meta_has_active_decommission(&reloaded));
|
|
assert!(replica_state.needs_repair);
|
|
assert!(replica_state.repair_write_safe);
|
|
|
|
let worker_cancel = CancellationToken::new();
|
|
let index_cancelers = store
|
|
.reserve_decommission_routines(&worker_cancel, &[0])
|
|
.await
|
|
.expect("the committed activation should admit its decommission worker");
|
|
drop(spawn_decommission_index_cancelers(
|
|
Arc::clone(&store),
|
|
worker_cancel.clone(),
|
|
index_cancelers,
|
|
Arc::new(Semaphore::new(decommission_entry_concurrency_limit())),
|
|
));
|
|
let admitted_cancel = store.decommission_cancelers.read().await[0]
|
|
.clone()
|
|
.expect("the admitted decommission worker should have a cancellation token");
|
|
assert!(!admitted_cancel.is_cancelled());
|
|
worker_cancel.cancel();
|
|
assert!(admitted_cancel.is_cancelled());
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial(pool_meta_version_env)]
|
|
async fn decommission_capacity_reservation_serializes_two_nodes_without_overselling() {
|
|
let (_temp_dirs, first_node, second_node) =
|
|
crate::services::rebalance::test_three_pool_stores_with_isolated_node_contexts(None).await;
|
|
persist_v3_pool_meta_for_test(&first_node).await;
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let capacity_snapshot = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, 30, 30),
|
|
DecommissionPoolCapacityInfo::for_test(2, layout, 100, 100, 0),
|
|
];
|
|
set_decommission_capacity_info_overrides_for_test(first_node.id, vec![capacity_snapshot.clone()]);
|
|
set_decommission_capacity_info_overrides_for_test(second_node.id, vec![capacity_snapshot]);
|
|
|
|
let barrier =
|
|
PutObjectCommitBarrier::install(RUSTFS_META_BUCKET, POOL_META_NAME, PutObjectCommitPause::BeforeQuotaRename);
|
|
let first_store = Arc::clone(&first_node);
|
|
let first = tokio::spawn(async move {
|
|
first_store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
});
|
|
barrier.wait_until_paused().await;
|
|
|
|
let second_probe = PoolActivationStartProbe::install(PoolActivationStartKind::Decommission);
|
|
let second_store = Arc::clone(&second_node);
|
|
let second = tokio::spawn(async move {
|
|
second_store
|
|
.save_current_pool_meta_for_decommission_start(&[1], Vec::new())
|
|
.await
|
|
});
|
|
second_probe.wait_until_attempted().await;
|
|
assert!(
|
|
!second.is_finished(),
|
|
"the competing node must wait behind the distributed activation fence"
|
|
);
|
|
|
|
drop(barrier);
|
|
first
|
|
.await
|
|
.expect("first-node activation should not panic")
|
|
.expect("first-node reservation should fit");
|
|
let mut started_v3 = PoolMeta::default();
|
|
started_v3
|
|
.load_no_lock_from_replicas(first_node.pools.clone())
|
|
.await
|
|
.expect("the started capacity reservation should reload from V3 replicas");
|
|
assert_eq!(started_v3.version, POOL_META_GENERATION_VERSION);
|
|
let err = second
|
|
.await
|
|
.expect("second-node activation should not panic")
|
|
.expect_err("the second reservation must observe and reject the committed first reservation");
|
|
assert!(err.to_string().contains("requires 60 bytes, but 40 bytes are available"));
|
|
|
|
let mut persisted = PoolMeta::default();
|
|
persisted
|
|
.load_no_lock_from_replicas(first_node.pools.clone())
|
|
.await
|
|
.expect("the winning capacity reservation should remain readable");
|
|
let reservation = persisted.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("the winning operation must retain its durable reservation");
|
|
assert_eq!(
|
|
reservation.model_version, DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION,
|
|
"an all-v4 proof must persist the per-target lock model through V3 replicas"
|
|
);
|
|
assert_eq!(reservation.peak_physical_bytes, 60);
|
|
assert!(persisted.pools[1].decommission.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn decommission_start_without_dedicated_v4_proof_persists_the_legacy_lock_model() {
|
|
let (_temp_dirs, store, _other_store) =
|
|
crate::services::rebalance::test_three_pool_stores_with_isolated_node_contexts(None).await;
|
|
crate::services::rebalance::promote_test_pool_meta_to_v2(&store).await;
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
set_decommission_capacity_info_overrides_for_test(
|
|
store.id,
|
|
vec![vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 10, 10),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, 10, 10),
|
|
DecommissionPoolCapacityInfo::for_test(2, layout, 40, 40, 0),
|
|
]],
|
|
);
|
|
let _proof_guard = crate::services::notification_sys::without_decommission_target_fence_fleet_proof_for_test();
|
|
|
|
store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
.expect("a mixed-version fleet should retain the compatible global lock model");
|
|
let reservation = store.pool_meta.read().await.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("proofless start should persist a capacity reservation")
|
|
.clone();
|
|
assert_eq!(reservation.model_version, DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn decommission_cancel_replans_target_fences_after_a_concurrent_v2_start() {
|
|
let (_temp_dirs, store, _other_store) =
|
|
crate::services::rebalance::test_three_pool_stores_with_isolated_node_contexts(None).await;
|
|
crate::services::rebalance::promote_test_pool_meta_to_v2(&store).await;
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
set_decommission_capacity_info_overrides_for_test(
|
|
store.id,
|
|
vec![vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 10, 10),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, 10, 10),
|
|
DecommissionPoolCapacityInfo::for_test(2, layout, 40, 40, 0),
|
|
]],
|
|
);
|
|
let barrier = super::DecommissionCapacityLockOrderBarrier::install(store.id, store.id);
|
|
barrier.pause_cancel_before_start();
|
|
let cancel_store = Arc::clone(&store);
|
|
let mut cancel = tokio::spawn(async move { cancel_store.decommission_cancel(0).await });
|
|
barrier.wait_until_cancel_before_start().await;
|
|
|
|
store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
.expect("the concurrent start should persist a v2 reservation");
|
|
let target_pool_index = store.pool_meta.read().await.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.map(|reservation| {
|
|
assert_eq!(reservation.model_version, DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION);
|
|
reservation.targets[0].pool_index
|
|
})
|
|
.expect("the concurrent start should publish its target cohort");
|
|
let target_lock = store.pools[0]
|
|
.new_ns_lock(
|
|
RUSTFS_META_BUCKET,
|
|
&format!("{}/{target_pool_index}", super::DECOMMISSION_CAPACITY_TARGET_LOCK_PREFIX),
|
|
)
|
|
.await
|
|
.expect("create the cancel/start target-fence probe");
|
|
let target_guard = target_lock
|
|
.get_write_lock(std::time::Duration::from_secs(30))
|
|
.await
|
|
.expect("hold the newly started target fence");
|
|
|
|
barrier.release_cancel_before_start();
|
|
tokio::time::timeout(std::time::Duration::from_millis(500), &mut cancel)
|
|
.await
|
|
.expect_err("cancel must wait for the target fence selected by the concurrent start");
|
|
assert!(
|
|
store.pool_meta.read().await.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.is_some_and(DecommissionCapacityReservation::active),
|
|
"cancel must not release the new reservation before fencing its target"
|
|
);
|
|
|
|
drop(target_guard);
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), cancel)
|
|
.await
|
|
.expect("cancel should finish after the target fence is released")
|
|
.expect("cancel task should not panic")
|
|
.expect("cancel should publish the terminal state");
|
|
drop(barrier);
|
|
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("canceled decommission metadata should remain present");
|
|
assert!(info.canceled);
|
|
let reservation = info
|
|
.capacity_reservation
|
|
.as_ref()
|
|
.expect("canceled capacity accounting should remain inspectable");
|
|
assert!(!reservation.active());
|
|
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
|
assert_eq!(reservation.inflight_target_physical_bytes, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn stale_node_cancel_cannot_replace_a_new_durable_v2_operation() {
|
|
let (_temp_dirs, first_node, stale_node) =
|
|
crate::services::rebalance::test_three_pool_stores_with_isolated_node_contexts(None).await;
|
|
crate::services::rebalance::promote_test_pool_meta_to_v2(&first_node).await;
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
set_decommission_capacity_info_overrides_for_test(
|
|
first_node.id,
|
|
vec![vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 10, 10),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, 10, 10),
|
|
DecommissionPoolCapacityInfo::for_test(2, layout, 40, 40, 0),
|
|
]],
|
|
);
|
|
|
|
first_node
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
.expect("start the operation retained by the stale node");
|
|
let stale_operation = first_node.pool_meta.read().await.clone();
|
|
let stale_plan = decommission_capacity_terminal_fence_plan(&stale_operation, 0)
|
|
.expect("the stale operation should have a valid terminal fence plan")
|
|
.expect("the stale operation should have an active reservation");
|
|
|
|
first_node
|
|
.decommission_cancel(0)
|
|
.await
|
|
.expect("cancel the first durable operation");
|
|
first_node
|
|
.clear_decommission(0)
|
|
.await
|
|
.expect("clear the first terminal operation");
|
|
first_node
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
.expect("start the replacement durable operation");
|
|
let replacement_plan = {
|
|
let pool_meta = first_node.pool_meta.read().await;
|
|
decommission_capacity_terminal_fence_plan(&pool_meta, 0)
|
|
.expect("the replacement operation should have a valid terminal fence plan")
|
|
.expect("the replacement operation should have an active reservation")
|
|
};
|
|
assert_ne!(replacement_plan.operation_id, stale_plan.operation_id);
|
|
|
|
*stale_node.pool_meta.write().await = stale_operation;
|
|
let err = stale_node
|
|
.decommission_cancel(0)
|
|
.await
|
|
.expect_err("a stale local operation must not be merged over the durable replacement");
|
|
assert!(err.to_string().contains("differs from the durable terminal fence plan"));
|
|
|
|
let mut durable = PoolMeta::default();
|
|
durable
|
|
.load_no_lock_from_replicas(first_node.pools.clone())
|
|
.await
|
|
.expect("the replacement operation should remain readable from durable replicas");
|
|
assert_eq!(
|
|
decommission_capacity_terminal_fence_plan(&durable, 0)
|
|
.expect("the durable replacement should retain a valid terminal fence plan")
|
|
.expect("the durable replacement reservation should remain active"),
|
|
replacement_plan
|
|
);
|
|
let replacement = durable.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("the durable replacement metadata should remain present");
|
|
assert!(!replacement.canceled);
|
|
assert!(
|
|
replacement
|
|
.capacity_reservation
|
|
.as_ref()
|
|
.is_some_and(DecommissionCapacityReservation::active)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn legacy_capacity_model_holds_the_global_pool_meta_fence_through_target_io() {
|
|
let (_temp_dirs, store, _other_store) =
|
|
crate::services::rebalance::test_three_pool_stores_with_isolated_node_contexts(None).await;
|
|
crate::services::rebalance::promote_test_pool_meta_to_v2(&store).await;
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
set_decommission_capacity_info_overrides_for_test(
|
|
store.id,
|
|
vec![vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 20, 100, 80),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 100, 100, 0),
|
|
DecommissionPoolCapacityInfo::for_test(2, layout, 100, 100, 0),
|
|
]],
|
|
);
|
|
let _proof_guard = crate::services::notification_sys::without_decommission_target_fence_fleet_proof_for_test();
|
|
store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
.expect("activate a legacy reservation spanning two targets");
|
|
let base_owner = {
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let reservation = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("legacy capacity reservation should exist");
|
|
assert_eq!(reservation.model_version, DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION);
|
|
assert_eq!(reservation.targets.iter().map(|target| target.pool_index).collect::<Vec<_>>(), vec![1, 2]);
|
|
DecommissionCapacityOwner {
|
|
source_pool_index: 0,
|
|
operation_id: reservation.operation_id,
|
|
generation: reservation.generation,
|
|
owner_nonce: reservation.owner_nonce,
|
|
mutation_id: None,
|
|
}
|
|
};
|
|
|
|
let (first_entered_tx, first_entered_rx) = tokio::sync::oneshot::channel();
|
|
let (first_release_tx, first_release_rx) = tokio::sync::oneshot::channel();
|
|
let first_store = Arc::clone(&store);
|
|
let first_owner = base_owner.with_mutation_id(uuid::Uuid::new_v4());
|
|
let first = tokio::spawn(async move {
|
|
first_store
|
|
.run_decommission_capacity_admitted_mutation_with_capacity_lease(1, Some(first_owner), Some(1), |_| async {
|
|
first_entered_tx.send(()).expect("first legacy mutation should be observed");
|
|
first_release_rx.await.expect("first legacy mutation should be released");
|
|
Ok(())
|
|
})
|
|
.await
|
|
});
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), first_entered_rx)
|
|
.await
|
|
.expect("first legacy mutation should enter")
|
|
.expect("first legacy mutation should report entry");
|
|
assert!(
|
|
store.pool_meta_save_gate.try_lock().is_err(),
|
|
"legacy target I/O must retain the local pool metadata save gate"
|
|
);
|
|
let pool_meta_lock = store.pools[0]
|
|
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
|
|
.await
|
|
.expect("create the legacy pool metadata lock probe");
|
|
assert!(
|
|
pool_meta_lock
|
|
.get_write_lock_quiet(std::time::Duration::from_millis(300))
|
|
.await
|
|
.is_err(),
|
|
"legacy target I/O must retain the distributed pool metadata write fence"
|
|
);
|
|
|
|
let (second_entered_tx, mut second_entered_rx) = tokio::sync::oneshot::channel();
|
|
let (second_release_tx, second_release_rx) = tokio::sync::oneshot::channel();
|
|
let second_store = Arc::clone(&store);
|
|
let second_owner = base_owner.with_mutation_id(uuid::Uuid::new_v4());
|
|
let second = tokio::spawn(async move {
|
|
second_store
|
|
.run_decommission_capacity_admitted_mutation_with_capacity_lease(2, Some(second_owner), Some(1), |_| async {
|
|
second_entered_tx.send(()).expect("second legacy mutation should be observed");
|
|
second_release_rx.await.expect("second legacy mutation should be released");
|
|
Ok(())
|
|
})
|
|
.await
|
|
});
|
|
tokio::time::timeout(std::time::Duration::from_millis(500), &mut second_entered_rx)
|
|
.await
|
|
.expect_err("a different target must still wait behind the legacy global fence");
|
|
|
|
first_release_tx.send(()).expect("release first legacy mutation");
|
|
first
|
|
.await
|
|
.expect("first legacy mutation task should not panic")
|
|
.expect("first legacy mutation should finalize");
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), &mut second_entered_rx)
|
|
.await
|
|
.expect("second legacy mutation should enter after the first finalizes")
|
|
.expect("second legacy mutation should report entry");
|
|
assert!(
|
|
pool_meta_lock
|
|
.get_write_lock_quiet(std::time::Duration::from_millis(300))
|
|
.await
|
|
.is_err(),
|
|
"the second legacy target I/O must also retain the global fence"
|
|
);
|
|
second_release_tx.send(()).expect("release second legacy mutation");
|
|
second
|
|
.await
|
|
.expect("second legacy mutation task should not panic")
|
|
.expect("second legacy mutation should finalize");
|
|
|
|
let pool_meta_guard = pool_meta_lock
|
|
.get_write_lock(std::time::Duration::from_secs(30))
|
|
.await
|
|
.expect("the global pool metadata fence should release after both target tails");
|
|
drop(pool_meta_guard);
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let reservation = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("legacy reservation should remain active");
|
|
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
|
assert_eq!(reservation.consumed_target_physical_bytes, 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn decommission_capacity_mutations_overlap_across_targets_but_serialize_per_target() {
|
|
let (_temp_dirs, store, _other_store) =
|
|
crate::services::rebalance::test_three_pool_stores_with_isolated_node_contexts(None).await;
|
|
crate::services::rebalance::promote_test_pool_meta_to_v2(&store).await;
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let capacity_snapshot = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 20, 100, 80),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 100, 100, 0),
|
|
DecommissionPoolCapacityInfo::for_test(2, layout, 100, 100, 0),
|
|
];
|
|
set_decommission_capacity_info_overrides_for_test(store.id, vec![capacity_snapshot]);
|
|
store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
.expect("activate a reservation spanning two targets");
|
|
|
|
let base_owner = {
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let reservation = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("active capacity reservation should exist");
|
|
assert_eq!(
|
|
reservation.targets.iter().map(|target| target.pool_index).collect::<Vec<_>>(),
|
|
vec![1, 2],
|
|
"the fixture must reserve both target pools"
|
|
);
|
|
DecommissionCapacityOwner {
|
|
source_pool_index: 0,
|
|
operation_id: reservation.operation_id,
|
|
generation: reservation.generation,
|
|
owner_nonce: reservation.owner_nonce,
|
|
mutation_id: None,
|
|
}
|
|
};
|
|
|
|
let (first_entered_tx, first_entered_rx) = tokio::sync::oneshot::channel();
|
|
let (first_release_tx, first_release_rx) = tokio::sync::oneshot::channel();
|
|
let first_store = Arc::clone(&store);
|
|
let first_owner = base_owner.with_mutation_id(uuid::Uuid::new_v4());
|
|
let first = tokio::spawn(async move {
|
|
first_store
|
|
.run_decommission_capacity_admitted_mutation_with_capacity_lease(1, Some(first_owner), Some(1), |_| async {
|
|
first_entered_tx.send(()).expect("first target mutation should be observed");
|
|
first_release_rx.await.expect("first target mutation should be released");
|
|
Ok(())
|
|
})
|
|
.await
|
|
});
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), first_entered_rx)
|
|
.await
|
|
.expect("first target mutation should enter without hanging")
|
|
.expect("first target mutation should report entry");
|
|
|
|
let (second_entered_tx, second_entered_rx) = tokio::sync::oneshot::channel();
|
|
let (second_release_tx, second_release_rx) = tokio::sync::oneshot::channel();
|
|
let second_store = Arc::clone(&store);
|
|
let second_owner = base_owner.with_mutation_id(uuid::Uuid::new_v4());
|
|
let second = tokio::spawn(async move {
|
|
second_store
|
|
.run_decommission_capacity_admitted_mutation_with_capacity_lease(2, Some(second_owner), Some(1), |_| async {
|
|
second_entered_tx.send(()).expect("second target mutation should be observed");
|
|
second_release_rx.await.expect("second target mutation should be released");
|
|
Ok(())
|
|
})
|
|
.await
|
|
});
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), second_entered_rx)
|
|
.await
|
|
.expect("a different target mutation should overlap instead of waiting for the first")
|
|
.expect("second target mutation should report entry");
|
|
|
|
let save_guard = store
|
|
.pool_meta_save_gate
|
|
.try_lock()
|
|
.expect("target I/O must not retain the local pool metadata save gate");
|
|
drop(save_guard);
|
|
let pool_meta_lock = store.pools[0]
|
|
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
|
|
.await
|
|
.expect("create a pool metadata lock probe");
|
|
let pool_meta_guard = pool_meta_lock
|
|
.get_write_lock_quiet(std::time::Duration::from_secs(1))
|
|
.await
|
|
.expect("target I/O must not retain the distributed pool metadata write lock");
|
|
drop(pool_meta_guard);
|
|
|
|
let blocked_owner = base_owner.with_mutation_id(uuid::Uuid::new_v4());
|
|
let blocked = store
|
|
.run_decommission_capacity_admitted_mutation(1, Some(blocked_owner), Some(1), || async { Ok(()) })
|
|
.await
|
|
.expect_err("a second mutation on the same target must wait behind its target gate");
|
|
assert!(is_decommission_capacity_blocked_error(&blocked));
|
|
assert!(
|
|
is_decommission_capacity_target_gate_busy(&blocked),
|
|
"same-target serialization must remain distinguishable from durable intent conflicts"
|
|
);
|
|
assert!(
|
|
decommission_capacity_retry_kind(&blocked, 0).is_none(),
|
|
"target contention must be retried inside the current version instead of replaying the entry"
|
|
);
|
|
|
|
let cancel_store = Arc::clone(&store);
|
|
let mut cancel = tokio::spawn(async move { cancel_store.decommission_cancel(0).await });
|
|
tokio::time::timeout(std::time::Duration::from_millis(500), &mut cancel)
|
|
.await
|
|
.expect_err("terminal reservation release must wait for prepared target mutations");
|
|
assert!(
|
|
store.pool_meta.read().await.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.is_some_and(DecommissionCapacityReservation::active),
|
|
"cancellation must not release the reservation while target mutations are active"
|
|
);
|
|
|
|
first_release_tx.send(()).expect("release first target mutation");
|
|
second_release_tx.send(()).expect("release second target mutation");
|
|
first
|
|
.await
|
|
.expect("first target mutation task should not panic")
|
|
.expect("first target mutation should finalize");
|
|
second
|
|
.await
|
|
.expect("second target mutation task should not panic")
|
|
.expect("second target mutation should finalize");
|
|
cancel
|
|
.await
|
|
.expect("decommission cancellation task should not panic")
|
|
.expect("decommission cancellation should finish after target mutations");
|
|
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let reservation = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("released capacity reservation should remain available for accounting");
|
|
assert!(!reservation.active());
|
|
assert_eq!(reservation.release_reason.as_deref(), Some(DECOMMISSION_CAPACITY_RELEASE_CANCELED));
|
|
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
|
assert_eq!(reservation.consumed_target_physical_bytes, 2);
|
|
assert_eq!(
|
|
reservation
|
|
.targets
|
|
.iter()
|
|
.map(|target| (target.pool_index, target.consumed_physical_bytes))
|
|
.collect::<Vec<_>>(),
|
|
vec![(1, 1), (2, 1)]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn ordinary_write_capacity_fence_serializes_with_decommission_activation() {
|
|
let (_temp_dirs, store, _other_store) = crate::services::rebalance::test_two_pool_stores(None).await;
|
|
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
|
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
|
let write_store = Arc::clone(&store);
|
|
let ordinary_write = tokio::spawn(async move {
|
|
write_store
|
|
.run_decommission_capacity_admitted_mutation(1, None, None, || async move {
|
|
entered_tx.send(()).expect("ordinary write admission should be observed");
|
|
release_rx.await.expect("ordinary write should be released");
|
|
Ok(())
|
|
})
|
|
.await
|
|
});
|
|
entered_rx
|
|
.await
|
|
.expect("ordinary write should hold the shared capacity fence");
|
|
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
set_decommission_capacity_info_overrides_for_test(
|
|
store.id,
|
|
vec![vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 59, 60, 1),
|
|
]],
|
|
);
|
|
let activation_store = Arc::clone(&store);
|
|
let activation = tokio::spawn(async move {
|
|
activation_store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
});
|
|
tokio::task::yield_now().await;
|
|
assert!(
|
|
!activation.is_finished(),
|
|
"activation must wait until the already-admitted ordinary write leaves the distributed capacity boundary"
|
|
);
|
|
|
|
release_tx.send(()).expect("ordinary write should be released");
|
|
ordinary_write
|
|
.await
|
|
.expect("ordinary write task should join")
|
|
.expect("the pre-activation ordinary write should complete");
|
|
let err = activation
|
|
.await
|
|
.expect("activation task should join")
|
|
.expect_err("activation must recheck and reject capacity consumed by the ordinary write");
|
|
assert!(err.to_string().contains("requires 60 bytes, but 59 bytes are available"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn multipart_mutations_locate_later_upload_before_reserved_pool_admission() {
|
|
let (_temp_dirs, store, _other_store) =
|
|
crate::services::rebalance::test_three_pool_stores_with_isolated_node_contexts(None).await;
|
|
let bucket = format!("multipart-capacity-routing-{}", uuid::Uuid::new_v4());
|
|
let object = "later-pool.bin";
|
|
store
|
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("create multipart routing bucket");
|
|
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("load bucket incarnation");
|
|
let bucket_guard = store
|
|
.acquire_bucket_lifecycle_read_lock(&bucket)
|
|
.await
|
|
.expect("acquire multipart routing bucket lifecycle guard");
|
|
let mut upload_opts = ObjectOptions {
|
|
expected_bucket_incarnation_id: Some(incarnation),
|
|
..Default::default()
|
|
};
|
|
upload_opts.add_bucket_lifecycle_lock_guard(&bucket_guard);
|
|
let upload = store.pools[1]
|
|
.new_multipart_upload(&bucket, object, &upload_opts)
|
|
.await
|
|
.expect("seed an upload in the later pool");
|
|
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
set_decommission_capacity_info_overrides_for_test(
|
|
store.id,
|
|
vec![vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 20, 20, 0),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 100, 100, 0),
|
|
DecommissionPoolCapacityInfo::for_test(2, layout, 0, 10, 10),
|
|
]],
|
|
);
|
|
store
|
|
.save_current_pool_meta_for_decommission_start(&[2], Vec::new())
|
|
.await
|
|
.expect("reserve the first target pool");
|
|
{
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let targets = &pool_meta.pools[2]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("source reservation should exist")
|
|
.targets;
|
|
assert_eq!(targets.len(), 1);
|
|
assert_eq!(targets[0].pool_index, 0, "the first probed pool must be reserved");
|
|
}
|
|
|
|
let mut data = crate::object_api::PutObjReader::from_vec(b"multipart body".to_vec());
|
|
let part = store
|
|
.put_object_part(&bucket, object, &upload.upload_id, 1, &mut data, &ObjectOptions::default())
|
|
.await
|
|
.expect("put-part must locate the later upload before capacity admission");
|
|
store
|
|
.clone()
|
|
.complete_multipart_upload(
|
|
&bucket,
|
|
object,
|
|
&upload.upload_id,
|
|
vec![crate::storage_api_contracts::multipart::CompletePart {
|
|
part_num: part.part_num,
|
|
etag: part.etag,
|
|
..Default::default()
|
|
}],
|
|
&ObjectOptions::default(),
|
|
)
|
|
.await
|
|
.expect("complete must locate the later upload before capacity admission");
|
|
|
|
store.pools[1]
|
|
.get_object_info(&bucket, object, &ObjectOptions::default())
|
|
.await
|
|
.expect("the later-pool multipart upload should commit in place");
|
|
let first_pool_result = store.pools[0]
|
|
.get_object_info(&bucket, object, &ObjectOptions::default())
|
|
.await;
|
|
assert!(
|
|
first_pool_result
|
|
.as_ref()
|
|
.err()
|
|
.is_some_and(|err| is_err_object_not_found(err) || is_err_version_not_found(err)),
|
|
"the reserved first pool must not receive the multipart mutation"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn expired_capacity_owner_nonce_rejects_stale_put_and_multipart_without_consuming() {
|
|
let (_temp_dirs, store, _other_store) = crate::services::rebalance::test_two_pool_stores(None).await;
|
|
let bucket = format!("stale-capacity-owner-{}", uuid::Uuid::new_v4());
|
|
let multipart_object = "stale-multipart.bin";
|
|
store
|
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("create stale owner bucket");
|
|
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("load bucket incarnation");
|
|
let bucket_guard = store
|
|
.acquire_bucket_lifecycle_read_lock(&bucket)
|
|
.await
|
|
.expect("acquire stale owner bucket lifecycle guard");
|
|
let mut upload_opts = ObjectOptions {
|
|
expected_bucket_incarnation_id: Some(incarnation),
|
|
..Default::default()
|
|
};
|
|
upload_opts.add_bucket_lifecycle_lock_guard(&bucket_guard);
|
|
let upload = store.pools[1]
|
|
.new_multipart_upload(&bucket, multipart_object, &upload_opts)
|
|
.await
|
|
.expect("seed multipart upload before reserving the target");
|
|
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
set_decommission_capacity_info_overrides_for_test(
|
|
store.id,
|
|
vec![vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 10, 10),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 100, 100, 0),
|
|
]],
|
|
);
|
|
store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
.expect("activate target reservation");
|
|
|
|
let stale_owner = {
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let reservation = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("active reservation should exist");
|
|
DecommissionCapacityOwner {
|
|
source_pool_index: 0,
|
|
operation_id: reservation.operation_id,
|
|
generation: reservation.generation,
|
|
owner_nonce: reservation.owner_nonce,
|
|
mutation_id: None,
|
|
}
|
|
};
|
|
{
|
|
let mut pool_meta = store.pool_meta.write().await;
|
|
let reservation = pool_meta.pools[0]
|
|
.decommission
|
|
.as_mut()
|
|
.and_then(|info| info.capacity_reservation.as_mut())
|
|
.expect("active reservation should remain mutable");
|
|
reservation.expires_at = OffsetDateTime::now_utc() - Duration::seconds(1);
|
|
}
|
|
store
|
|
.save_current_pool_meta(&[0])
|
|
.await
|
|
.expect("persist the expired lease before recovery");
|
|
store
|
|
.pause_decommission_for_capacity(0, &decommission_capacity_blocked_error("test lease recovery"))
|
|
.await
|
|
.expect("pause recovery should renew the lease with a new owner nonce");
|
|
|
|
let (current_owner, before_progress) = {
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let reservation = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("renewed reservation should exist");
|
|
(
|
|
DecommissionCapacityOwner {
|
|
source_pool_index: 0,
|
|
operation_id: reservation.operation_id,
|
|
generation: reservation.generation,
|
|
owner_nonce: reservation.owner_nonce,
|
|
mutation_id: None,
|
|
},
|
|
(
|
|
reservation.consumed_target_physical_bytes,
|
|
reservation.inflight_target_physical_bytes,
|
|
reservation.pending_target_physical_bytes,
|
|
reservation.observed_target_physical_bytes,
|
|
),
|
|
)
|
|
};
|
|
assert_ne!(stale_owner.owner_nonce, current_owner.owner_nonce);
|
|
{
|
|
let pool_meta = store.pool_meta.read().await;
|
|
ensure_decommission_target_owner_admission(&pool_meta, current_owner, 1, 1, OffsetDateTime::now_utc())
|
|
.expect("the renewed owner should retain its reservation");
|
|
assert!(
|
|
ensure_decommission_target_owner_admission(&pool_meta, stale_owner, 1, 1, OffsetDateTime::now_utc(),).is_err(),
|
|
"the expired owner token must be rejected after nonce rotation"
|
|
);
|
|
}
|
|
|
|
let stale_put_object = "stale-put.bin";
|
|
let mut stale_put_opts = ObjectOptions {
|
|
data_movement: true,
|
|
version_id: Some(uuid::Uuid::new_v4().to_string()),
|
|
..ObjectOptions::with_capacity_expected_data_bytes(Some(9))
|
|
};
|
|
stale_owner.apply_to(&mut stale_put_opts);
|
|
let mut stale_put_data = crate::object_api::PutObjReader::from_vec(b"stale put".to_vec());
|
|
let put_err = store
|
|
.put_object_for_data_movement(&bucket, stale_put_object, &mut stale_put_data, &stale_put_opts, None)
|
|
.await
|
|
.expect_err("stale owner PUT must fail before selecting a target");
|
|
assert!(is_decommission_capacity_blocked_error(&put_err));
|
|
|
|
let mut stale_part_opts = ObjectOptions {
|
|
data_movement: true,
|
|
part_number: Some(1),
|
|
expected_bucket_incarnation_id: Some(incarnation),
|
|
..Default::default()
|
|
};
|
|
stale_owner.apply_to(&mut stale_part_opts);
|
|
let mut stale_part_data = crate::object_api::PutObjReader::from_vec(b"stale part".to_vec());
|
|
let part_err = store
|
|
.put_object_part_for_data_movement(
|
|
1,
|
|
&bucket,
|
|
multipart_object,
|
|
&upload.upload_id,
|
|
&mut stale_part_data,
|
|
&stale_part_opts,
|
|
)
|
|
.await
|
|
.expect_err("stale owner multipart PUT must fail before mutation");
|
|
assert!(is_decommission_capacity_blocked_error(&part_err));
|
|
|
|
let after_progress = {
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let reservation = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("renewed reservation should remain active");
|
|
(
|
|
reservation.consumed_target_physical_bytes,
|
|
reservation.inflight_target_physical_bytes,
|
|
reservation.pending_target_physical_bytes,
|
|
reservation.observed_target_physical_bytes,
|
|
)
|
|
};
|
|
assert_eq!(after_progress, before_progress, "stale mutations must not consume the capacity ledger");
|
|
let parts = store.pools[1]
|
|
.list_object_parts(
|
|
&bucket,
|
|
multipart_object,
|
|
&upload.upload_id,
|
|
None,
|
|
1_000,
|
|
&ObjectOptions {
|
|
expected_bucket_incarnation_id: Some(incarnation),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("the seeded upload should remain readable");
|
|
assert!(parts.parts.is_empty(), "the stale multipart write must not stage a part");
|
|
assert!(
|
|
store.pools[1]
|
|
.get_object_info(&bucket, stale_put_object, &ObjectOptions::default())
|
|
.await
|
|
.is_err(),
|
|
"the stale PUT must not create a target object"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn decommission_activation_lock_recheck_rejects_sudden_space_drop_without_persisting() {
|
|
let (_temp_dirs, store, _other_store) = crate::services::rebalance::test_two_pool_stores(None).await;
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let source = DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30);
|
|
let preflight = vec![source, DecommissionPoolCapacityInfo::for_test(1, layout, 60, 60, 0)];
|
|
{
|
|
let pool_meta = store.pool_meta.read().await;
|
|
ensure_decommission_start_target_capacity(&pool_meta, &[0], &preflight, true)
|
|
.expect("the pre-lock capacity snapshot should fit exactly");
|
|
}
|
|
|
|
set_decommission_capacity_info_overrides_for_test(
|
|
store.id,
|
|
vec![vec![source, DecommissionPoolCapacityInfo::for_test(1, layout, 59, 60, 1)]],
|
|
);
|
|
let err = store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
.expect_err("the capacity snapshot inside the activation lock must be authoritative");
|
|
assert!(err.to_string().contains("requires 60 bytes, but 59 bytes are available"));
|
|
|
|
let local = store.pool_meta.read().await;
|
|
assert!(local.pools[0].decommission.is_none());
|
|
drop(local);
|
|
let mut persisted = PoolMeta::default();
|
|
persisted
|
|
.load_no_lock_from_replicas(store.pools.clone())
|
|
.await
|
|
.expect("the rejected activation must leave baseline metadata readable");
|
|
assert!(persisted.pools[0].decommission.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn decommission_worker_pauses_on_runtime_capacity_shortage_without_source_side_effect() {
|
|
let (_temp_dirs, store, _other_store) = crate::services::rebalance::test_two_pool_stores(None).await;
|
|
let bucket = format!("capacity-blocked-{}", uuid::Uuid::new_v4());
|
|
let object = "object.bin";
|
|
store
|
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("the production-path bucket should be created");
|
|
let mut source = crate::object_api::PutObjReader::from_vec(b"source remains authoritative".to_vec());
|
|
store.pools[0]
|
|
.put_object(&bucket, object, &mut source, &ObjectOptions::default())
|
|
.await
|
|
.expect("the source object should be written before decommission starts");
|
|
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let enough = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 60, 60, 0),
|
|
];
|
|
set_decommission_capacity_info_overrides_for_test(store.id, vec![enough.clone()]);
|
|
store
|
|
.save_current_pool_meta_for_decommission_start(
|
|
&[0],
|
|
vec![DecomBucketInfo {
|
|
name: bucket.clone(),
|
|
prefix: String::new(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("the initial reservation should be activated");
|
|
|
|
let shortage = vec![enough[0], DecommissionPoolCapacityInfo::for_test(1, layout, 59, 60, 1)];
|
|
set_decommission_capacity_info_overrides_for_test(store.id, vec![shortage]);
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
store.decommission_cancelers.write().await[0] = Some(canceler.clone());
|
|
store
|
|
.do_decommission_in_routine(canceler, 0, Arc::new(Semaphore::new(1)))
|
|
.await
|
|
.expect("runtime capacity shortage should pause the worker, not fail it");
|
|
|
|
store.pools[0]
|
|
.get_object_info(&bucket, object, &ObjectOptions::default())
|
|
.await
|
|
.expect("capacity pause must not delete the source object");
|
|
let target_result = store.pools[1]
|
|
.get_object_info(&bucket, object, &ObjectOptions::default())
|
|
.await;
|
|
assert!(
|
|
target_result
|
|
.as_ref()
|
|
.err()
|
|
.is_some_and(|err| is_err_object_not_found(err) || is_err_version_not_found(err)),
|
|
"capacity pause must happen before a target mutation"
|
|
);
|
|
|
|
let local = store.pool_meta.read().await;
|
|
let info = local.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("the blocked decommission state should remain present");
|
|
assert!(
|
|
!info.complete && !info.failed && !info.canceled,
|
|
"capacity-blocked state unexpectedly became terminal: complete={}, failed={}, canceled={}, blocked_reason={:?}",
|
|
info.complete,
|
|
info.failed,
|
|
info.canceled,
|
|
info.capacity_blocked_reason
|
|
);
|
|
assert_eq!(info.items_decommission_failed, 0);
|
|
assert_eq!(info.bytes_failed, 0);
|
|
assert!(info.capacity_blocked_reason.is_some());
|
|
assert!(
|
|
info.capacity_reservation
|
|
.as_ref()
|
|
.is_some_and(DecommissionCapacityReservation::active),
|
|
"blocked work must retain and renew its reservation"
|
|
);
|
|
drop(local);
|
|
|
|
let mut persisted = PoolMeta::default();
|
|
persisted
|
|
.load_no_lock_from_replicas(store.pools.clone())
|
|
.await
|
|
.expect("the blocked state should be durable");
|
|
let persisted_info = persisted.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("the durable blocked state should remain nonterminal");
|
|
assert!(!persisted_info.complete && !persisted_info.failed && !persisted_info.canceled);
|
|
assert!(persisted_info.capacity_blocked_reason.is_some());
|
|
assert!(
|
|
persisted_info
|
|
.capacity_reservation
|
|
.as_ref()
|
|
.is_some_and(DecommissionCapacityReservation::active)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn decommission_worker_rechecks_runtime_capacity_before_empty_background_completion() {
|
|
let (_temp_dirs, store, _other_store) = crate::services::rebalance::test_two_pool_stores(None).await;
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let enough = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 60, 60, 0),
|
|
];
|
|
set_decommission_capacity_info_overrides_for_test(store.id, vec![enough.clone()]);
|
|
store
|
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
.await
|
|
.expect("the initial reservation should be activated");
|
|
|
|
let shortage = vec![enough[0], DecommissionPoolCapacityInfo::for_test(1, layout, 59, 60, 1)];
|
|
set_decommission_capacity_info_overrides_for_test(store.id, vec![shortage]);
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
store.decommission_cancelers.write().await[0] = Some(canceler.clone());
|
|
store
|
|
.do_decommission_in_routine(canceler, 0, Arc::new(Semaphore::new(1)))
|
|
.await
|
|
.expect("runtime capacity shortage should pause the worker before background completion");
|
|
|
|
let local = store.pool_meta.read().await;
|
|
let info = local.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("the blocked decommission state should remain present");
|
|
assert!(!info.complete && !info.failed && !info.canceled);
|
|
assert!(info.capacity_blocked_reason.is_some());
|
|
assert!(
|
|
info.capacity_reservation
|
|
.as_ref()
|
|
.is_some_and(DecommissionCapacityReservation::active)
|
|
);
|
|
}
|
|
|
|
fn pool_meta_replica_test_meta(cmd_line: &str) -> PoolMeta {
|
|
PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: cmd_line.to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
}],
|
|
dont_save: false,
|
|
}
|
|
}
|
|
|
|
fn pool_meta_replica_test_data(cmd_line: &str) -> Vec<u8> {
|
|
pool_meta_replica_test_meta(cmd_line)
|
|
.encode_config_data()
|
|
.expect("pool metadata should encode")
|
|
}
|
|
|
|
fn pool_meta_v1_replica_test_data(meta: &PoolMeta) -> Vec<u8> {
|
|
let mut data = Vec::new();
|
|
data.write_u16::<LittleEndian>(POOL_META_FORMAT)
|
|
.expect("pool metadata format should encode");
|
|
data.write_u16::<LittleEndian>(POOL_META_V1_VERSION)
|
|
.expect("pool metadata version should encode");
|
|
PersistedPoolMetaV1::from(meta)
|
|
.serialize(&mut Serializer::new(&mut data))
|
|
.expect("legacy pool metadata should encode");
|
|
data
|
|
}
|
|
|
|
fn pool_meta_persisted_v1_replica_test_data(cmd_line: &str) -> Vec<u8> {
|
|
pool_meta_v1_replica_test_data(&pool_meta_replica_test_meta(cmd_line))
|
|
}
|
|
|
|
fn pool_meta_legacy_v1_replica_test_data(cmd_line: &str) -> Vec<u8> {
|
|
let mut data = Vec::new();
|
|
data.write_u16::<LittleEndian>(POOL_META_FORMAT)
|
|
.expect("pool metadata format should encode");
|
|
data.write_u16::<LittleEndian>(POOL_META_V1_VERSION)
|
|
.expect("pool metadata version should encode");
|
|
LegacyPoolMeta {
|
|
version: POOL_META_V1_VERSION,
|
|
pools: vec![LegacyPoolStatus {
|
|
id: 0,
|
|
cmd_line: cmd_line.to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
}],
|
|
dont_save: false,
|
|
}
|
|
.serialize(&mut Serializer::new(&mut data))
|
|
.expect("legacy v1 pool metadata should encode");
|
|
data
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_selection_falls_back_from_corrupt_first_copy() {
|
|
let selection = select_pool_meta_replica(vec![
|
|
PoolMetaReplica::Corrupt("truncated".to_string()),
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-0")),
|
|
])
|
|
.expect("a validated backup replica should be selected");
|
|
|
|
assert!(selection.replica_state.needs_repair);
|
|
assert!(selection.replica_state.repair_write_safe);
|
|
assert_eq!(selection.meta.pools.len(), 1);
|
|
assert_eq!(selection.meta.pools[0].cmd_line, "pool-0");
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_selection_rejects_incompatible_copy() {
|
|
let valid = pool_meta_replica_test_data("pool-0");
|
|
let mut incompatible = valid.clone();
|
|
LittleEndian::write_u16(&mut incompatible[2..4], POOL_META_VERSION + 1);
|
|
|
|
let err = select_pool_meta_replica(vec![decode_pool_meta_replica(valid), decode_pool_meta_replica(incompatible)])
|
|
.expect_err("an incompatible replica must block fallback and repair writes");
|
|
|
|
assert!(err.to_string().contains("pool 1 is incompatible"));
|
|
assert!(err.to_string().contains("without overwriting it"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_selection_prefers_valid_canonical_first_copy() {
|
|
let selection = select_pool_meta_replica(vec![
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-canonical")),
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-stale")),
|
|
])
|
|
.expect("pool zero is the durable commit record when a later replica is stale");
|
|
|
|
assert_eq!(selection.meta.pools[0].cmd_line, "pool-canonical");
|
|
assert!(selection.replica_state.needs_repair);
|
|
assert!(selection.replica_state.repair_write_safe);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_selection_rejects_divergent_backups_without_canonical() {
|
|
let err = select_pool_meta_replica(vec![
|
|
PoolMetaReplica::Corrupt("canonical unavailable".to_string()),
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-old")),
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-new")),
|
|
])
|
|
.expect_err("divergent backups have no safe ordering when the canonical copy is unavailable");
|
|
|
|
assert!(err.to_string().contains("valid replicas in pools 1 and 2 diverge"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_selection_normalizes_equivalent_legacy_copy() {
|
|
let selection = select_pool_meta_replica(vec![
|
|
decode_pool_meta_replica(pool_meta_legacy_v1_replica_test_data("pool-0")),
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-0")),
|
|
])
|
|
.expect("equivalent legacy and current encodings should be compatible");
|
|
|
|
assert!(selection.replica_state.needs_repair);
|
|
assert!(selection.replica_state.repair_write_safe);
|
|
assert_eq!(selection.meta.pools[0].cmd_line, "pool-0");
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v1_replica_migrates_to_v2_canonical_form() {
|
|
let mut source = pool_meta_replica_test_meta("pool-0");
|
|
source.pools[0].decommission = Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
queued_buckets: vec!["bucket-a".to_string()],
|
|
..Default::default()
|
|
});
|
|
let data = pool_meta_v1_replica_test_data(&source);
|
|
let PoolMetaReplica::Valid {
|
|
raw, canonical, meta, ..
|
|
} = decode_pool_meta_replica(data)
|
|
else {
|
|
panic!("v1 pool metadata should remain readable");
|
|
};
|
|
|
|
assert_eq!(LittleEndian::read_u16(&raw[2..4]), POOL_META_V1_VERSION);
|
|
assert_eq!(LittleEndian::read_u16(&canonical[2..4]), POOL_META_VERSION);
|
|
assert_eq!(meta.version, POOL_META_V1_VERSION);
|
|
let info = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("v1 decommission state should migrate");
|
|
assert_eq!(info.queued_buckets, vec!["bucket-a".to_string()]);
|
|
assert!(info.unresolved_entries.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_legacy_v1_replica_migrates_to_v2_canonical_form() {
|
|
let data = pool_meta_legacy_v1_replica_test_data("pool-0");
|
|
let PoolMetaReplica::Valid {
|
|
raw, canonical, meta, ..
|
|
} = decode_pool_meta_replica(data)
|
|
else {
|
|
panic!("legacy v1 pool metadata should remain readable");
|
|
};
|
|
|
|
assert_eq!(LittleEndian::read_u16(&raw[2..4]), POOL_META_V1_VERSION);
|
|
assert_eq!(LittleEndian::read_u16(&canonical[2..4]), POOL_META_VERSION);
|
|
assert_eq!(meta.version, POOL_META_V1_VERSION);
|
|
assert_eq!(meta.pools[0].cmd_line, "pool-0");
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_selection_rejects_v1_v2_divergence() {
|
|
let err = select_pool_meta_replica(vec![
|
|
decode_pool_meta_replica(pool_meta_persisted_v1_replica_test_data("pool-old")),
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-new")),
|
|
])
|
|
.expect_err("different v1 and v2 snapshots must remain fail-closed");
|
|
|
|
assert!(err.to_string().contains("valid replicas in pools 0 and 1 diverge"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_rejects_header_payload_version_mismatch() {
|
|
let mut v1_with_v2_header = pool_meta_persisted_v1_replica_test_data("pool-0");
|
|
LittleEndian::write_u16(&mut v1_with_v2_header[2..4], POOL_META_VERSION);
|
|
assert!(matches!(decode_pool_meta_replica(v1_with_v2_header), PoolMetaReplica::Corrupt(_)));
|
|
|
|
let mut v2_with_v1_header = pool_meta_replica_test_data("pool-0");
|
|
LittleEndian::write_u16(&mut v2_with_v1_header[2..4], POOL_META_V1_VERSION);
|
|
assert!(matches!(
|
|
decode_pool_meta_replica(v2_with_v1_header),
|
|
PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v2_header_guards_v1_readers_from_tuple_extension() {
|
|
let mut meta = pool_meta_replica_test_meta("pool-0");
|
|
meta.pools[0].decommission = Some(PoolDecommissionInfo::default());
|
|
let data = meta.encode_config_data().expect("v2 pool metadata should encode");
|
|
|
|
assert_eq!(LittleEndian::read_u16(&data[2..4]), POOL_META_VERSION);
|
|
assert_ne!(POOL_META_VERSION, POOL_META_V1_VERSION);
|
|
assert!(rmp_serde::from_slice::<PersistedPoolMetaV1>(&data[4..]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_writer_stays_v1_until_v2_is_fleet_confirmed() {
|
|
let mut meta = pool_meta_replica_test_meta("pool-0");
|
|
meta.version = POOL_META_V1_VERSION;
|
|
|
|
let v1 = meta
|
|
.encode_config_data_for_v2_gate(false)
|
|
.expect("the default writer should preserve v1");
|
|
let v2 = meta
|
|
.encode_config_data_for_v2_gate(true)
|
|
.expect("the confirmed writer should emit v2");
|
|
|
|
assert_eq!(LittleEndian::read_u16(&v1[2..4]), POOL_META_V1_VERSION);
|
|
assert_eq!(LittleEndian::read_u16(&v2[2..4]), POOL_META_VERSION);
|
|
assert!(rmp_serde::from_slice::<PersistedPoolMetaV1>(&v1[4..]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v2_writer_requires_both_gates() {
|
|
assert!(!pool_meta_v2_writer_enabled_for(false, false));
|
|
assert!(!pool_meta_v2_writer_enabled_for(true, false));
|
|
assert!(!pool_meta_v2_writer_enabled_for(false, true));
|
|
assert!(pool_meta_v2_writer_enabled_for(true, true));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v3_writer_requires_both_gates() {
|
|
assert!(!pool_meta_v3_writer_enabled_for(false, false));
|
|
assert!(!pool_meta_v3_writer_enabled_for(true, false));
|
|
assert!(!pool_meta_v3_writer_enabled_for(false, true));
|
|
assert!(pool_meta_v3_writer_enabled_for(true, true));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_ledger_persistence_requires_an_observed_or_confirmed_format() {
|
|
for (version, v2_enabled, v3_enabled, expected) in [
|
|
(POOL_META_V1_VERSION, false, false, false),
|
|
(POOL_META_V1_VERSION, true, false, true),
|
|
(POOL_META_V1_VERSION, false, true, true),
|
|
(POOL_META_VERSION, false, false, true),
|
|
(super::POOL_META_GENERATION_VERSION, false, false, true),
|
|
] {
|
|
let result = super::ensure_decommission_ledger_persistence_supported_for(version, v2_enabled, v3_enabled);
|
|
assert_eq!(
|
|
result.is_ok(),
|
|
expected,
|
|
"unexpected capability result for pool metadata version {version}"
|
|
);
|
|
}
|
|
|
|
let half_confirmed_v2 = pool_meta_v2_writer_enabled_for(true, false);
|
|
let half_confirmed_v3 = pool_meta_v3_writer_enabled_for(false, true);
|
|
let err = super::ensure_decommission_ledger_persistence_supported_for(
|
|
POOL_META_V1_VERSION,
|
|
half_confirmed_v2,
|
|
half_confirmed_v3,
|
|
)
|
|
.expect_err("half-enabled rollout gates must not admit decommission");
|
|
assert!(matches!(err, Error::InvalidArgument(..)));
|
|
assert!(err.to_string().contains("durable unresolved-entry recovery"));
|
|
assert!(err.to_string().contains(rustfs_config::ENV_POOL_META_V3_WRITE));
|
|
assert!(err.to_string().contains(rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_capacity_accepts_v3_only_writer_rollout() {
|
|
assert!(decommission_capacity_writer_supported_for(
|
|
POOL_META_V1_VERSION,
|
|
false,
|
|
pool_meta_v3_writer_enabled_for(true, true),
|
|
));
|
|
assert!(!decommission_capacity_writer_supported_for(
|
|
POOL_META_V1_VERSION,
|
|
false,
|
|
pool_meta_v3_writer_enabled_for(true, false),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_stale_write_rejection_metric_is_countable() {
|
|
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
|
let snapshotter = recorder.snapshotter();
|
|
metrics::with_local_recorder(&recorder, || {
|
|
record_pool_meta_stale_write_rejection("prepare_cas");
|
|
record_pool_meta_stale_write_rejection("prepare_cas");
|
|
});
|
|
|
|
let total = snapshotter
|
|
.snapshot()
|
|
.into_vec()
|
|
.into_iter()
|
|
.filter(|(composite, _, _, _)| composite.key().name() == METRIC_POOL_META_STALE_WRITE_REJECTIONS_TOTAL)
|
|
.filter_map(|(_, _, _, value)| match value {
|
|
metrics_util::debugging::DebugValue::Counter(value) => Some(value),
|
|
_ => None,
|
|
})
|
|
.sum::<u64>();
|
|
assert_eq!(total, 2);
|
|
}
|
|
|
|
fn pool_meta_v3_test_revision(cluster_id: uuid::Uuid, generation: u64, transaction_id: uuid::Uuid) -> PoolMetaRevision {
|
|
PoolMetaRevision {
|
|
version: POOL_META_GENERATION_VERSION,
|
|
cluster_id: Some(cluster_id),
|
|
epoch: POOL_META_INITIAL_EPOCH,
|
|
generation,
|
|
transaction_id: Some(transaction_id),
|
|
}
|
|
}
|
|
|
|
fn pool_meta_legacy_candidate(meta: PoolMeta) -> PoolMetaCommittedCandidate {
|
|
PoolMetaCommittedCandidate {
|
|
canonical: meta
|
|
.encode_config_data_for_v2_gate(true)
|
|
.expect("legacy pool metadata should encode"),
|
|
revision: PoolMetaRevision::legacy(meta.version),
|
|
meta,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v3_pending_prepare_recovers_previous_snapshot_after_restart() {
|
|
let previous = pool_meta_replica_test_meta("pool-before");
|
|
let mut next = pool_meta_replica_test_meta("pool-after");
|
|
next.version = POOL_META_GENERATION_VERSION;
|
|
let revision = pool_meta_v3_test_revision(uuid::Uuid::new_v4(), 1, uuid::Uuid::new_v4());
|
|
let pending = encode_pool_meta_v3_envelope(&next, revision, false, Some(&pool_meta_legacy_candidate(previous.clone())))
|
|
.expect("pending generation should encode");
|
|
|
|
let selected = select_pool_meta_replica(vec![
|
|
decode_pool_meta_replica(pending),
|
|
decode_pool_meta_replica(
|
|
previous
|
|
.encode_config_data_for_v2_gate(true)
|
|
.expect("previous snapshot should encode"),
|
|
),
|
|
])
|
|
.expect("a prepare-only transaction should expose its committed predecessor");
|
|
|
|
assert_eq!(selected.meta.pools[0].cmd_line, "pool-before");
|
|
assert_eq!(selected.revision.version, POOL_META_VERSION);
|
|
assert!(selected.replica_state.needs_repair);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v3_partial_commit_selects_new_generation_after_restart() {
|
|
let previous = pool_meta_replica_test_meta("pool-before");
|
|
let mut next = pool_meta_replica_test_meta("pool-after");
|
|
next.version = POOL_META_GENERATION_VERSION;
|
|
let revision = pool_meta_v3_test_revision(uuid::Uuid::new_v4(), 1, uuid::Uuid::new_v4());
|
|
let pending = encode_pool_meta_v3_envelope(&next, revision, false, Some(&pool_meta_legacy_candidate(previous)))
|
|
.expect("pending generation should encode");
|
|
let committed = encode_pool_meta_v3_envelope(&next, revision, true, None).expect("committed generation should encode");
|
|
|
|
let selected = select_pool_meta_replica(vec![decode_pool_meta_replica(committed), decode_pool_meta_replica(pending)])
|
|
.expect("one committed replica should make the cross-pool transaction durable");
|
|
|
|
assert_eq!(selected.meta.pools[0].cmd_line, "pool-after");
|
|
assert_eq!(selected.revision, revision);
|
|
assert!(selected.replica_state.needs_repair);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v3_uses_valid_committed_backup_but_rejects_same_generation_fork() {
|
|
let cluster_id = uuid::Uuid::new_v4();
|
|
let mut next = pool_meta_replica_test_meta("pool-after");
|
|
next.version = POOL_META_GENERATION_VERSION;
|
|
let revision = pool_meta_v3_test_revision(cluster_id, 7, uuid::Uuid::new_v4());
|
|
let committed = encode_pool_meta_v3_envelope(&next, revision, true, None).expect("committed generation should encode");
|
|
let selected = select_pool_meta_replica(vec![
|
|
PoolMetaReplica::Corrupt("truncated".to_string()),
|
|
decode_pool_meta_replica(committed.clone()),
|
|
])
|
|
.expect("a corrupt first copy should fall back to a valid committed generation");
|
|
assert_eq!(selected.revision, revision);
|
|
assert!(selected.replica_state.needs_repair);
|
|
|
|
let fork_revision = pool_meta_v3_test_revision(cluster_id, 7, uuid::Uuid::new_v4());
|
|
let fork = encode_pool_meta_v3_envelope(&next, fork_revision, true, None).expect("fork generation should encode");
|
|
let err = select_pool_meta_replica(vec![decode_pool_meta_replica(committed), decode_pool_meta_replica(fork)])
|
|
.expect_err("same-generation transactions must never be selected by pool order");
|
|
assert!(err.to_string().contains("committed generation 7 diverges"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v3_migration_keeps_legacy_committed_until_commit_record_exists() {
|
|
let previous = pool_meta_replica_test_meta("pool-before");
|
|
let mut next = pool_meta_replica_test_meta("pool-after");
|
|
next.version = POOL_META_GENERATION_VERSION;
|
|
let revision = pool_meta_v3_test_revision(uuid::Uuid::new_v4(), 1, uuid::Uuid::new_v4());
|
|
let pending = encode_pool_meta_v3_envelope(&next, revision, false, Some(&pool_meta_legacy_candidate(previous.clone())))
|
|
.expect("pending migration should encode");
|
|
let legacy = previous
|
|
.encode_config_data_for_v2_gate(true)
|
|
.expect("legacy snapshot should encode");
|
|
|
|
let prepared = select_pool_meta_replica(vec![decode_pool_meta_replica(pending), decode_pool_meta_replica(legacy)])
|
|
.expect("prepared migration should retain the legacy commit");
|
|
assert_eq!(prepared.meta.version, POOL_META_VERSION);
|
|
assert_eq!(prepared.meta.pools[0].cmd_line, "pool-before");
|
|
|
|
let committed = encode_pool_meta_v3_envelope(&next, revision, true, None).expect("committed migration should encode");
|
|
let migrated = select_pool_meta_replica(vec![decode_pool_meta_replica(committed)])
|
|
.expect("committed migration should establish the V3 floor");
|
|
assert_eq!(migrated.meta.version, POOL_META_GENERATION_VERSION);
|
|
assert_eq!(migrated.meta.pools[0].cmd_line, "pool-after");
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v3_unknown_fields_remain_incompatible_not_silently_ignored() {
|
|
#[derive(Serialize)]
|
|
struct FuturePoolMetaV3 {
|
|
version: u16,
|
|
cluster_id: String,
|
|
epoch: u64,
|
|
generation: u64,
|
|
transaction_id: String,
|
|
committed: bool,
|
|
pools: Vec<PersistedPoolStatus>,
|
|
previous: Option<PersistedPoolMetaV3Previous>,
|
|
future_guard: u64,
|
|
}
|
|
|
|
let mut data = Vec::new();
|
|
data.write_u16::<LittleEndian>(POOL_META_FORMAT)
|
|
.expect("pool metadata format should encode");
|
|
data.write_u16::<LittleEndian>(POOL_META_GENERATION_VERSION)
|
|
.expect("pool metadata version should encode");
|
|
FuturePoolMetaV3 {
|
|
version: POOL_META_GENERATION_VERSION,
|
|
cluster_id: uuid::Uuid::new_v4().to_string(),
|
|
epoch: POOL_META_INITIAL_EPOCH,
|
|
generation: 1,
|
|
transaction_id: uuid::Uuid::new_v4().to_string(),
|
|
committed: true,
|
|
pools: Vec::new(),
|
|
previous: None,
|
|
future_guard: 1,
|
|
}
|
|
.serialize(&mut Serializer::new(&mut data))
|
|
.expect("future V3 payload should encode");
|
|
|
|
assert!(matches!(decode_pool_meta_replica(data), PoolMetaReplica::Incompatible(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_identity_classifies_corrupt_incompatible_and_divergent_replicas() {
|
|
let cluster_id = uuid::Uuid::new_v4();
|
|
let identity = PersistedPoolMetaIdentity {
|
|
version: POOL_META_IDENTITY_VERSION,
|
|
cluster_id,
|
|
epoch: POOL_META_INITIAL_EPOCH,
|
|
initialized: true,
|
|
fresh_bootstrap_nonce: None,
|
|
};
|
|
let selected = select_pool_meta_identity(
|
|
vec![
|
|
PoolMetaIdentityRead {
|
|
replica: PoolMetaIdentityReplica::Corrupt("truncated".to_string()),
|
|
cas: PoolMetaCasToken::Existing("corrupt".to_string()),
|
|
},
|
|
PoolMetaIdentityRead {
|
|
replica: PoolMetaIdentityReplica::Valid(identity),
|
|
cas: PoolMetaCasToken::Existing("valid".to_string()),
|
|
},
|
|
],
|
|
cluster_id,
|
|
)
|
|
.expect("a verified identity backup should survive a corrupt first replica");
|
|
assert_eq!(selected.identity, Some(identity));
|
|
assert!(selected.needs_repair);
|
|
|
|
let incompatible = select_pool_meta_identity(
|
|
vec![PoolMetaIdentityRead {
|
|
replica: PoolMetaIdentityReplica::Incompatible("future version".to_string()),
|
|
cas: PoolMetaCasToken::Existing("future".to_string()),
|
|
}],
|
|
cluster_id,
|
|
)
|
|
.expect_err("an incompatible identity must fail closed");
|
|
assert!(incompatible.to_string().contains("incompatible"));
|
|
|
|
let divergent = select_pool_meta_identity(
|
|
vec![
|
|
PoolMetaIdentityRead {
|
|
replica: PoolMetaIdentityReplica::Valid(identity),
|
|
cas: PoolMetaCasToken::Existing("epoch-1".to_string()),
|
|
},
|
|
PoolMetaIdentityRead {
|
|
replica: PoolMetaIdentityReplica::Valid(PersistedPoolMetaIdentity {
|
|
epoch: POOL_META_INITIAL_EPOCH + 1,
|
|
..identity
|
|
}),
|
|
cas: PoolMetaCasToken::Existing("epoch-2".to_string()),
|
|
},
|
|
],
|
|
cluster_id,
|
|
)
|
|
.expect_err("identity epoch divergence must require recovery");
|
|
assert!(divergent.to_string().contains("recovery required"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v2_floor_is_sticky_after_observation() {
|
|
let meta = pool_meta_replica_test_meta("pool-0");
|
|
let data = meta
|
|
.encode_config_data_for_v2_gate(false)
|
|
.expect("an observed v2 snapshot must not be downgraded");
|
|
|
|
assert_eq!(LittleEndian::read_u16(&data[2..4]), POOL_META_VERSION);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v1_writer_rejects_unresolved_ledger() {
|
|
let mut meta = pool_meta_replica_test_meta("pool-0");
|
|
meta.version = POOL_META_V1_VERSION;
|
|
meta.pools[0].decommission = Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
unresolved_entries: vec![DecommissionUnresolvedEntry {
|
|
bucket: "bucket-a".to_string(),
|
|
object: "object-a".to_string(),
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
source_generation: OffsetDateTime::UNIX_EPOCH,
|
|
candidate_count: 1,
|
|
disk_error_count: 1,
|
|
observed_at: OffsetDateTime::UNIX_EPOCH,
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
}],
|
|
..Default::default()
|
|
});
|
|
|
|
let err = meta
|
|
.encode_config_data_for_v2_gate(false)
|
|
.expect_err("v1 must not drop the unresolved ledger");
|
|
assert!(err.to_string().contains("pool metadata V2 is required"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v1_writer_rejects_capacity_reservations() {
|
|
let mut meta = pool_meta_replica_test_meta("pool-0");
|
|
meta.version = POOL_META_V1_VERSION;
|
|
let reservation = build_decommission_capacity_reservation(
|
|
DecommissionPoolCapacityInfo::for_test(0, DecommissionErasureLayout { data: 1, parity: 0 }, 0, 10, 10),
|
|
DecommissionErasureLayout { data: 1, parity: 0 },
|
|
uuid::Uuid::new_v4(),
|
|
1,
|
|
OffsetDateTime::UNIX_EPOCH,
|
|
)
|
|
.expect("test reservation should be valid");
|
|
meta.pools[0].decommission = Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
capacity_reservation: Some(reservation),
|
|
..Default::default()
|
|
});
|
|
|
|
let err = meta
|
|
.encode_config_data_for_v2_gate(false)
|
|
.expect_err("v1 must not drop a distributed capacity reservation");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("pool metadata V2 is required to persist decommission capacity reservations")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_selection_preserves_observed_v2_floor() {
|
|
let selection = select_pool_meta_replica(vec![
|
|
decode_pool_meta_replica(pool_meta_persisted_v1_replica_test_data("pool-0")),
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-0")),
|
|
])
|
|
.expect("equivalent v1 and v2 replicas should converge");
|
|
|
|
assert_eq!(selection.meta.version, POOL_META_VERSION);
|
|
let data = selection
|
|
.meta
|
|
.encode_config_data_for_v2_gate(false)
|
|
.expect("the selected v2 floor must remain writable");
|
|
assert_eq!(LittleEndian::read_u16(&data[2..4]), POOL_META_VERSION);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_selection_distinguishes_absent_from_unrecoverable() {
|
|
assert!(matches!(decode_pool_meta_replica(Vec::new()), PoolMetaReplica::Corrupt(_)));
|
|
|
|
let empty = select_pool_meta_replica(vec![PoolMetaReplica::Missing, PoolMetaReplica::Missing])
|
|
.expect("all missing replicas should preserve new-deployment behavior");
|
|
assert!(empty.meta.pools.is_empty());
|
|
assert!(!empty.replica_state.needs_repair);
|
|
assert!(empty.replica_state.repair_write_safe);
|
|
|
|
let err = select_pool_meta_replica(vec![
|
|
PoolMetaReplica::Missing,
|
|
PoolMetaReplica::Unreadable("read quorum unavailable".to_string()),
|
|
])
|
|
.expect_err("an unreadable replica must not be treated as a new deployment");
|
|
assert!(err.to_string().contains("no valid committed replica is available"));
|
|
assert!(err.to_string().contains("pool 1 is unreadable"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_selection_blocks_repair_for_unreadable_copy() {
|
|
let selection = select_pool_meta_replica(vec![
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-0")),
|
|
PoolMetaReplica::Unreadable("read quorum unavailable".to_string()),
|
|
])
|
|
.expect("a validated replica should remain usable while another copy is unreadable");
|
|
|
|
assert!(selection.replica_state.needs_repair);
|
|
assert!(!selection.replica_state.repair_write_safe);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_write_state_remains_blocked_after_unreadable_replica() {
|
|
let mut write_state = PoolMetaWriteState::default();
|
|
write_state.observe_replicas(PoolMetaReplicaState {
|
|
needs_repair: true,
|
|
repair_write_safe: false,
|
|
});
|
|
write_state.observe_replicas(PoolMetaReplicaState {
|
|
needs_repair: false,
|
|
repair_write_safe: true,
|
|
});
|
|
|
|
let err = write_state
|
|
.ensure_write_safe("pool metadata save failed")
|
|
.expect_err("a later clean read must not clear the startup write block");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("restart after all replicas are readable and consistent")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_write_state_blocks_when_selection_has_no_valid_replica() {
|
|
let replicas = vec![
|
|
PoolMetaReplica::Unreadable("pool 0 read quorum unavailable".to_string()),
|
|
PoolMetaReplica::Unreadable("pool 1 read quorum unavailable".to_string()),
|
|
];
|
|
let mut write_state = PoolMetaWriteState::default();
|
|
|
|
select_pool_meta_replicas_observing(&mut write_state, replicas).expect_err("all unreadable replicas must fail selection");
|
|
|
|
let err = write_state
|
|
.ensure_write_safe("pool metadata save failed")
|
|
.expect_err("an all-unreadable runtime read must latch the write block");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("restart after all replicas are readable and consistent")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_write_state_blocks_on_any_recovery_required_selection() {
|
|
fn assert_selection_blocks(replicas: Vec<PoolMetaReplica>) {
|
|
let mut write_state = PoolMetaWriteState::default();
|
|
select_pool_meta_replicas_observing(&mut write_state, replicas)
|
|
.expect_err("recovery-required replicas must fail selection");
|
|
write_state
|
|
.ensure_write_safe("pool metadata save failed")
|
|
.expect_err("a recovery-required selection must latch the write block");
|
|
}
|
|
|
|
assert_selection_blocks(vec![PoolMetaReplica::Corrupt("truncated".to_string())]);
|
|
assert_selection_blocks(vec![PoolMetaReplica::Incompatible("future format".to_string())]);
|
|
assert_selection_blocks(vec![
|
|
PoolMetaReplica::Corrupt("canonical unavailable".to_string()),
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-old")),
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-new")),
|
|
]);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_selection_rejects_same_version_tuple_extension() {
|
|
#[derive(Serialize)]
|
|
struct FuturePersistedPoolMeta {
|
|
version: u16,
|
|
pools: Vec<PersistedPoolStatus>,
|
|
generation: u64,
|
|
}
|
|
|
|
let mut data = Vec::new();
|
|
data.write_u16::<LittleEndian>(POOL_META_FORMAT)
|
|
.expect("pool metadata format should encode");
|
|
data.write_u16::<LittleEndian>(POOL_META_VERSION)
|
|
.expect("pool metadata version should encode");
|
|
FuturePersistedPoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: Vec::new(),
|
|
generation: 2,
|
|
}
|
|
.serialize(&mut Serializer::new(&mut data))
|
|
.expect("extended tuple pool metadata should encode");
|
|
|
|
let err = select_pool_meta_replica(vec![
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-0")),
|
|
decode_pool_meta_replica(data),
|
|
])
|
|
.expect_err("same-version tuple extensions must block fallback repair writes");
|
|
|
|
assert!(err.to_string().contains("pool 1 is incompatible"));
|
|
assert!(err.to_string().contains("version 2 tuple has unsupported field count"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_replica_selection_falls_back_from_truncated_current_tuple() {
|
|
let mut truncated = pool_meta_replica_test_data("pool-truncated");
|
|
truncated.pop();
|
|
|
|
let selection = select_pool_meta_replica(vec![
|
|
decode_pool_meta_replica(truncated),
|
|
decode_pool_meta_replica(pool_meta_replica_test_data("pool-valid")),
|
|
])
|
|
.expect("a truncated tuple should not block a validated backup replica");
|
|
|
|
assert!(selection.replica_state.needs_repair);
|
|
assert!(selection.replica_state.repair_write_safe);
|
|
assert_eq!(selection.meta.pools[0].cmd_line, "pool-valid");
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_pool_not_left_in_cmdline_after_decommission_allows_active_pool() {
|
|
assert!(ensure_pool_not_left_in_cmdline_after_decommission(0, "http://node{1...4}/disk{1...4}", false).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_pool_not_left_in_cmdline_after_decommission_rejects_completed_pool() {
|
|
let err = ensure_pool_not_left_in_cmdline_after_decommission(1, "http://node{1...4}/disk{1...4}", true)
|
|
.expect_err("completed decommissioned pool should fail validation");
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("pool(2) = http://node{1...4}/disk{1...4} is decommissioned, please remove from server command line")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn determine_decommission_final_state_marks_failures_and_cancellations() {
|
|
assert_eq!(determine_decommission_final_state(0, false), DecommissionFinalState::Complete);
|
|
assert_eq!(determine_decommission_final_state(1, false), DecommissionFinalState::Failed);
|
|
assert_eq!(determine_decommission_final_state(0, true), DecommissionFinalState::Failed);
|
|
}
|
|
|
|
#[test]
|
|
fn lifecycle_action_removes_data_movement_version_rejects_delete_marker_action() {
|
|
assert!(!lifecycle_action_removes_data_movement_version(IlmAction::DeleteAction));
|
|
}
|
|
|
|
#[test]
|
|
fn lifecycle_action_removes_data_movement_version_accepts_version_delete_actions() {
|
|
assert!(lifecycle_action_removes_data_movement_version(IlmAction::DeleteVersionAction));
|
|
assert!(lifecycle_action_removes_data_movement_version(IlmAction::DeleteAllVersionsAction));
|
|
assert!(lifecycle_action_removes_data_movement_version(
|
|
IlmAction::DelMarkerDeleteAllVersionsAction
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn lifecycle_action_skips_heal_version_for_every_delete_action() {
|
|
assert!(lifecycle_action_skips_heal_version(IlmAction::DeleteAction));
|
|
assert!(lifecycle_action_skips_heal_version(IlmAction::DeleteVersionAction));
|
|
assert!(lifecycle_action_skips_heal_version(IlmAction::DeleteRestoredAction));
|
|
assert!(lifecycle_action_skips_heal_version(IlmAction::DeleteRestoredVersionAction));
|
|
assert!(lifecycle_action_skips_heal_version(IlmAction::DeleteAllVersionsAction));
|
|
assert!(lifecycle_action_skips_heal_version(IlmAction::DelMarkerDeleteAllVersionsAction));
|
|
assert!(!lifecycle_action_skips_heal_version(IlmAction::TransitionAction));
|
|
assert!(!lifecycle_action_skips_heal_version(IlmAction::TransitionVersionAction));
|
|
assert!(!lifecycle_action_skips_heal_version(IlmAction::NoneAction));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_data_movement_lifecycle_expiry_result_allows_dry_run_skip() {
|
|
let skip = resolve_data_movement_lifecycle_expiry_result(IlmAction::DeleteVersionAction, false, false)
|
|
.expect("dry-run lifecycle evaluation should not require expiry enqueue");
|
|
|
|
assert!(skip);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_data_movement_lifecycle_expiry_result_rejects_apply_failure() {
|
|
let err = resolve_data_movement_lifecycle_expiry_result(IlmAction::DeleteVersionAction, true, false)
|
|
.expect_err("failed lifecycle expiry enqueue should not be treated as skipped");
|
|
|
|
assert!(err.to_string().contains("failed to apply lifecycle expiry action"));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_copy_cleanup_safe_error_accepts_missing_source_errors() {
|
|
assert!(is_decommission_copy_cleanup_safe_error(&Error::ObjectNotFound(
|
|
"bucket".to_string(),
|
|
"object".to_string()
|
|
)));
|
|
assert!(is_decommission_copy_cleanup_safe_error(&Error::VersionNotFound(
|
|
"bucket".to_string(),
|
|
"object".to_string(),
|
|
"version".to_string()
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_free_version_attempt_treats_missing_source_as_consumed() {
|
|
let attempt =
|
|
classify_decommission_free_version_attempt(Err(Error::ObjectNotFound("bucket".to_string(), "object".to_string())));
|
|
|
|
assert!(matches!(attempt, DecommissionFreeVersionAttempt::Consumed));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_free_version_attempt_preserves_capacity_failure() {
|
|
let attempt = classify_decommission_free_version_attempt(Err(Error::DiskFull));
|
|
|
|
assert!(matches!(attempt, DecommissionFreeVersionAttempt::CapacityFailure(Error::DiskFull)));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_delete_marker_copy_error_rejects_data_movement_overwrite() {
|
|
let err = Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string());
|
|
|
|
assert!(!is_decommission_copy_cleanup_safe_error(&err));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_remote_tiered_copy_error_rejects_data_movement_overwrite() {
|
|
let err = Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string());
|
|
|
|
assert!(!is_decommission_copy_cleanup_safe_error(&err));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_target_capacity_error_accepts_direct_capacity_errors() {
|
|
assert!(is_decommission_target_capacity_error(&Error::DiskFull));
|
|
assert!(is_decommission_target_capacity_error(&Error::StorageFull));
|
|
}
|
|
|
|
/// The decommission loop classifies errors that came back through a
|
|
/// data-movement stage wrapper. Before backlog#1827 T2 the wrapper flattened
|
|
/// everything into `Error::other(String)`, so these two classifiers had to
|
|
/// match on rendered text; now the wrapped error is recoverable by type.
|
|
#[test]
|
|
fn decommission_classifiers_see_through_a_stage_wrapper() {
|
|
let wrap = |inner: Error| {
|
|
data_movement::data_movement_stage_error_for_test("decommission_object", "put_object", "bucket-a", "object-a", inner)
|
|
};
|
|
|
|
// Capacity: the target pool filling up must still stop the loop.
|
|
assert!(is_decommission_target_capacity_error(&wrap(Error::DiskFull)));
|
|
assert!(is_decommission_target_capacity_error(&wrap(Error::StorageFull)));
|
|
assert!(!is_decommission_target_capacity_error(&wrap(Error::SlowDown)));
|
|
|
|
let gate_busy = decommission_capacity_blocked_error(format!(
|
|
"{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_PREFIX}7{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_SUFFIX}"
|
|
));
|
|
assert_eq!(decommission_capacity_target_gate_busy_index(&wrap(gate_busy)), Some(7));
|
|
|
|
// Cleanup safety: a not-found surfacing from inside a stage is the same
|
|
// condition as one surfacing directly, so the source entry stays
|
|
// eligible for cleanup.
|
|
let not_found = Error::ObjectNotFound("bucket-a".to_string(), "object-a".to_string());
|
|
assert!(is_decommission_copy_cleanup_safe_error(¬_found));
|
|
assert!(is_decommission_copy_cleanup_safe_error(&wrap(not_found)));
|
|
assert!(!is_decommission_copy_cleanup_safe_error(&wrap(Error::SlowDown)));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_target_capacity_error_accepts_wrapped_capacity_errors() {
|
|
let disk_full = Error::other(format!("decommission_object: put_object failed for bucket/object: {}", Error::DiskFull));
|
|
let storage_full = Error::other(format!(
|
|
"decommission_object: put_object failed for bucket/object: {}",
|
|
Error::StorageFull
|
|
));
|
|
|
|
assert!(is_decommission_target_capacity_error(&disk_full));
|
|
assert!(is_decommission_target_capacity_error(&storage_full));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_capacity_intent_conflict_accepts_context_wrapped_error() {
|
|
let err = with_decommission_entry_context(
|
|
"migrate_object",
|
|
"bucket",
|
|
"object",
|
|
decommission_capacity_blocked_error("target has an unresolved target capacity intent"),
|
|
);
|
|
|
|
assert!(is_decommission_capacity_intent_conflict(&err));
|
|
assert_eq!(
|
|
decommission_capacity_retry_kind(&err, DECOMMISSION_CAPACITY_INTENT_CONFLICT_MAX_ATTEMPTS - 1),
|
|
Some(DecommissionCapacityRetryKind::IntentConflict)
|
|
);
|
|
assert_eq!(
|
|
decommission_capacity_retry_kind(&err, DECOMMISSION_CAPACITY_INTENT_CONFLICT_MAX_ATTEMPTS),
|
|
None,
|
|
"a durable intent conflict must retain its bounded recovery policy"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_target_capacity_error_rejects_unrelated_errors() {
|
|
assert!(!is_decommission_target_capacity_error(&Error::SlowDown));
|
|
}
|
|
|
|
#[test]
|
|
fn should_skip_decommission_delete_marker_characterizes_empty_marker_without_replication() {
|
|
let version = rustfs_filemeta::FileInfo {
|
|
deleted: true,
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(should_skip_decommission_delete_marker(&version, 1, false));
|
|
}
|
|
|
|
#[test]
|
|
fn should_skip_decommission_delete_marker_characterizes_replication_configured() {
|
|
let version = rustfs_filemeta::FileInfo {
|
|
deleted: true,
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(!should_skip_decommission_delete_marker(&version, 1, true));
|
|
}
|
|
|
|
#[test]
|
|
fn should_skip_decommission_delete_marker_rejects_non_deleted_versions() {
|
|
let version = rustfs_filemeta::FileInfo::default();
|
|
|
|
assert!(!should_skip_decommission_delete_marker(&version, 1, false));
|
|
}
|
|
|
|
#[test]
|
|
fn should_skip_decommission_delete_marker_rejects_multiple_remaining_versions() {
|
|
let version = rustfs_filemeta::FileInfo {
|
|
deleted: true,
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(!should_skip_decommission_delete_marker(&version, 2, false));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_delete_marker_opts_preserves_replication_state() {
|
|
let mod_time = OffsetDateTime::now_utc();
|
|
let version = rustfs_filemeta::FileInfo {
|
|
mod_time: Some(mod_time),
|
|
replication_state_internal: Some(crate::bucket::replication::replication_state_to_filemeta(&ReplicationState {
|
|
replica_status: ReplicationStatusType::Replica,
|
|
delete_marker: true,
|
|
replicate_decision_str: "existing".to_string(),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
};
|
|
|
|
let incarnation = uuid::Uuid::new_v4();
|
|
let opts = decommission_delete_marker_opts(&version, Some("version-id".to_string()), 7, Some(incarnation));
|
|
let replication = opts.delete_replication.expect("replication state should be preserved");
|
|
|
|
assert!(opts.versioned);
|
|
assert!(opts.data_movement);
|
|
assert!(opts.delete_marker);
|
|
assert!(opts.skip_decommissioned);
|
|
assert_eq!(opts.src_pool_idx, 7);
|
|
assert_eq!(opts.version_id.as_deref(), Some("version-id"));
|
|
assert_eq!(opts.mod_time, Some(mod_time));
|
|
assert_eq!(opts.expected_bucket_incarnation_id, Some(incarnation));
|
|
assert_eq!(replication.replica_status, ReplicationStatusType::Replica);
|
|
assert!(replication.delete_marker);
|
|
assert_eq!(replication.replicate_decision_str, "existing");
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_delete_marker_opts_preserves_suspended_null_version() {
|
|
let version = rustfs_filemeta::FileInfo {
|
|
name: "object".to_string(),
|
|
deleted: true,
|
|
..Default::default()
|
|
};
|
|
let opts = decommission_delete_marker_opts(&version, None, 7, None);
|
|
|
|
assert!(!opts.versioned);
|
|
assert!(opts.version_suspended);
|
|
assert_eq!(opts.version_id.as_deref(), Some(uuid::Uuid::nil().to_string().as_str()));
|
|
|
|
let owner = DecommissionCapacityOwner {
|
|
source_pool_index: 7,
|
|
operation_id: uuid::Uuid::new_v4(),
|
|
generation: 1,
|
|
owner_nonce: uuid::Uuid::new_v4(),
|
|
mutation_id: None,
|
|
};
|
|
assert_eq!(
|
|
decommission_capacity_version_mutation_id(owner, "bucket", &version),
|
|
decommission_capacity_mutation_id(
|
|
owner,
|
|
"bucket",
|
|
&version.name,
|
|
opts.version_id.as_deref(),
|
|
opts.delete_marker,
|
|
opts.mod_time,
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_object_migration_read_opts_are_raw_data_movement() {
|
|
let opts = decommission_object_migration_read_opts(Some("vid-1".to_string()));
|
|
|
|
assert_eq!(opts.version_id.as_deref(), Some("vid-1"));
|
|
assert!(opts.no_lock);
|
|
assert!(opts.data_movement);
|
|
assert!(opts.raw_data_movement_read);
|
|
assert!(opts.skip_rebalancing);
|
|
assert!(opts.skip_decommissioned);
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_remote_tiered_opts_preserves_versioning_context() {
|
|
let mod_time = OffsetDateTime::now_utc();
|
|
let version = rustfs_filemeta::FileInfo {
|
|
mod_time: Some(mod_time),
|
|
metadata: HashMap::from([("x-amz-meta-key".to_string(), "value".to_string())]),
|
|
..Default::default()
|
|
};
|
|
|
|
let incarnation = uuid::Uuid::new_v4();
|
|
let opts = decommission_remote_tiered_opts(&version, Some("version-id".to_string()), 9, Some(incarnation));
|
|
|
|
assert!(opts.versioned);
|
|
assert!(opts.data_movement);
|
|
assert_eq!(opts.src_pool_idx, 9);
|
|
assert_eq!(opts.version_id.as_deref(), Some("version-id"));
|
|
assert_eq!(opts.mod_time, Some(mod_time));
|
|
assert_eq!(opts.user_defined.get("x-amz-meta-key").map(String::as_str), Some("value"));
|
|
assert!(opts.include_part_checksums);
|
|
assert!(opts.http_preconditions.is_some());
|
|
assert_eq!(opts.expected_bucket_incarnation_id, Some(incarnation));
|
|
assert!(!opts.incl_free_versions);
|
|
|
|
let mut free_version = version;
|
|
free_version.set_tier_free_version();
|
|
let free_opts = decommission_remote_tiered_opts(&free_version, Some("free-version-id".to_string()), 9, Some(incarnation));
|
|
assert!(free_opts.incl_free_versions);
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_terminal_state_transitions_update_start_time() {
|
|
let start_time = OffsetDateTime::now_utc();
|
|
let build_pool_meta = || PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "/tmp/pool".to_string(),
|
|
last_update: start_time,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(start_time),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
dont_save: true,
|
|
};
|
|
|
|
let mut pool_meta = build_pool_meta();
|
|
assert!(pool_meta.decommission_failed(0));
|
|
assert_eq!(pool_meta.pools[0].decommission.as_ref().and_then(|info| info.start_time), None);
|
|
|
|
let mut pool_meta = build_pool_meta();
|
|
assert!(pool_meta.decommission_complete(0));
|
|
assert_eq!(
|
|
pool_meta.pools[0].decommission.as_ref().and_then(|info| info.start_time),
|
|
Some(start_time)
|
|
);
|
|
|
|
let mut pool_meta = build_pool_meta();
|
|
assert!(pool_meta.decommission_cancel(0));
|
|
assert_eq!(pool_meta.pools[0].decommission.as_ref().and_then(|info| info.start_time), None);
|
|
|
|
let mut pool_meta = build_pool_meta();
|
|
assert!(pool_meta.decommission_cancel(0));
|
|
assert!(!pool_meta.decommission_complete(0));
|
|
|
|
let mut pool_meta = build_pool_meta();
|
|
assert!(pool_meta.decommission_failed(0));
|
|
assert!(!pool_meta.decommission_complete(0));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_persists_decommission_resume_queues() {
|
|
let start_time = OffsetDateTime::now_utc();
|
|
let unresolved_entry = DecommissionUnresolvedEntry {
|
|
bucket: "bucket-b".to_string(),
|
|
object: "prefix/unresolved.txt".to_string(),
|
|
pool_index: 0,
|
|
set_index: 1,
|
|
source_generation: start_time,
|
|
candidate_count: 2,
|
|
disk_error_count: 1,
|
|
observed_at: start_time,
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
};
|
|
let pool_meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![PoolStatus {
|
|
id: 1,
|
|
cmd_line: "/data/pool1/disk{1...4}".to_string(),
|
|
last_update: start_time,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(start_time),
|
|
queued: true,
|
|
queued_buckets: vec!["bucket-a".to_string(), "bucket-b/prefix".to_string()],
|
|
decommissioned_buckets: vec!["bucket-done".to_string()],
|
|
bucket: "bucket-b".to_string(),
|
|
prefix: "prefix".to_string(),
|
|
object: "object.txt".to_string(),
|
|
items_decommissioned: 7,
|
|
items_decommission_failed: 1,
|
|
bytes_done: 1024,
|
|
bytes_failed: 128,
|
|
terminal_reload_attempt_at: Some(start_time),
|
|
terminal_reload_failures: vec!["complete_decommission: peer node-a failed".to_string()],
|
|
unresolved_entries: vec![unresolved_entry.clone()],
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
dont_save: false,
|
|
};
|
|
|
|
let mut buf = Vec::new();
|
|
PersistedPoolMeta::from(&pool_meta)
|
|
.serialize(&mut Serializer::new(&mut buf))
|
|
.expect("pool meta should serialize");
|
|
|
|
let mut deserializer = Deserializer::new(Cursor::new(&buf));
|
|
let restored: PoolMeta = PersistedPoolMeta::deserialize(&mut deserializer)
|
|
.expect("pool meta should deserialize")
|
|
.try_into()
|
|
.expect("pool meta should validate");
|
|
|
|
let restored_decommission = restored.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("decommission info should survive round-trip");
|
|
assert_eq!(
|
|
restored_decommission.queued_buckets,
|
|
vec!["bucket-a".to_string(), "bucket-b/prefix".to_string()]
|
|
);
|
|
assert_eq!(restored_decommission.decommissioned_buckets, vec!["bucket-done".to_string()]);
|
|
assert_eq!(restored_decommission.bucket, "bucket-b");
|
|
assert_eq!(restored_decommission.prefix, "prefix");
|
|
assert_eq!(restored_decommission.object, "object.txt");
|
|
assert!(restored_decommission.stage.is_empty());
|
|
assert_eq!(restored_decommission.items_decommissioned, 7);
|
|
assert_eq!(restored_decommission.items_decommission_failed, 1);
|
|
assert_eq!(restored_decommission.bytes_done, 1024);
|
|
assert_eq!(restored_decommission.bytes_failed, 128);
|
|
assert_eq!(restored_decommission.terminal_reload_attempt_at, Some(start_time));
|
|
assert_eq!(
|
|
restored_decommission.terminal_reload_failures,
|
|
vec!["complete_decommission: peer node-a failed".to_string()]
|
|
);
|
|
assert_eq!(restored_decommission.unresolved_entries, vec![unresolved_entry]);
|
|
assert!(restored_decommission.queued);
|
|
assert_eq!(restored_decommission.items_since_last_progress_save(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_records_decommission_terminal_reload_failure_once() {
|
|
let start_time = OffsetDateTime::now_utc();
|
|
let mut pool_meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![PoolStatus {
|
|
id: 1,
|
|
cmd_line: "/data/pool1/disk{1...4}".to_string(),
|
|
last_update: start_time,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
dont_save: false,
|
|
};
|
|
|
|
assert!(
|
|
pool_meta
|
|
.record_decommission_terminal_reload_failure(0, "complete_decommission", "peer node-a failed".to_string())
|
|
.expect("terminal reload failure should be recorded")
|
|
);
|
|
assert!(
|
|
!pool_meta
|
|
.record_decommission_terminal_reload_failure(0, "complete_decommission", "peer node-a failed".to_string())
|
|
.expect("duplicate terminal reload failure should be ignored")
|
|
);
|
|
|
|
let decommission = pool_meta.pools[0].decommission.as_ref().expect("decommission should exist");
|
|
assert!(decommission.terminal_reload_attempt_at.is_some());
|
|
assert_eq!(
|
|
decommission.terminal_reload_failures,
|
|
vec!["complete_decommission: peer node-a failed".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_decode_supports_legacy_payload() {
|
|
let start_time = OffsetDateTime::now_utc();
|
|
let legacy_meta = LegacyPoolMeta {
|
|
version: POOL_META_V1_VERSION,
|
|
pools: vec![LegacyPoolStatus {
|
|
id: 3,
|
|
cmd_line: "/legacy/pool".to_string(),
|
|
last_update: start_time,
|
|
decommission: Some(LegacyPoolDecommissionInfo {
|
|
start_time: Some(start_time),
|
|
items_decommissioned: 9,
|
|
items_decommission_failed: 2,
|
|
bytes_done: 2048,
|
|
bytes_failed: 256,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
dont_save: true,
|
|
};
|
|
|
|
let mut legacy_payload = Vec::new();
|
|
legacy_meta
|
|
.serialize(&mut Serializer::new(&mut legacy_payload))
|
|
.expect("legacy payload should serialize");
|
|
|
|
// New persisted schema has fewer top-level fields and should not decode this legacy struct payload.
|
|
let persisted_decode: std::result::Result<PersistedPoolMeta, _> = rmp_serde::from_slice(&legacy_payload);
|
|
assert!(persisted_decode.is_err());
|
|
|
|
let decoded =
|
|
PoolMeta::decode_pool_meta_payload(POOL_META_V1_VERSION, &legacy_payload).expect("legacy payload should decode");
|
|
assert_eq!(decoded.version, POOL_META_V1_VERSION);
|
|
assert!(!decoded.dont_save, "runtime-only flag should reset on load");
|
|
assert_eq!(decoded.pools.len(), 1);
|
|
assert_eq!(decoded.pools[0].id, 3);
|
|
assert_eq!(decoded.pools[0].cmd_line, "/legacy/pool");
|
|
assert_eq!(decoded.pools[0].last_update, start_time);
|
|
|
|
let decommission = decoded.pools[0].decommission.as_ref().expect("decommission should decode");
|
|
assert_eq!(decommission.start_time, Some(start_time));
|
|
assert_eq!(decommission.items_decommissioned, 9);
|
|
assert_eq!(decommission.items_decommission_failed, 2);
|
|
assert_eq!(decommission.bytes_done, 2048);
|
|
assert_eq!(decommission.bytes_failed, 256);
|
|
assert_eq!(decommission.items_since_last_progress_save(), 0);
|
|
// These fields were skipped in legacy payload and should be defaulted.
|
|
assert!(decommission.queued_buckets.is_empty());
|
|
assert!(decommission.decommissioned_buckets.is_empty());
|
|
assert!(decommission.bucket.is_empty());
|
|
assert!(decommission.prefix.is_empty());
|
|
assert!(decommission.object.is_empty());
|
|
assert!(decommission.unresolved_entries.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_decode_rejects_unknown_legacy_fields() {
|
|
#[derive(Serialize)]
|
|
struct LegacyPoolMetaWithUnknownField {
|
|
version: u16,
|
|
pools: Vec<LegacyPoolStatus>,
|
|
dont_save: bool,
|
|
unexpected: bool,
|
|
}
|
|
|
|
let payload = rmp_serde::to_vec_named(&LegacyPoolMetaWithUnknownField {
|
|
version: POOL_META_V1_VERSION,
|
|
pools: Vec::new(),
|
|
dont_save: true,
|
|
unexpected: true,
|
|
})
|
|
.expect("legacy pool metadata with unknown field should serialize");
|
|
|
|
let err = PoolMeta::decode_pool_meta_payload(POOL_META_V1_VERSION, payload.as_slice())
|
|
.expect_err("unknown legacy pool metadata field should fail decode");
|
|
let rendered = err.to_string();
|
|
assert!(rendered.contains("PoolMeta v1 decode failed for both persisted and legacy formats"));
|
|
assert!(rendered.contains("unknown field") || rendered.contains("missing field"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_decode_rejects_unknown_persisted_fields() {
|
|
#[derive(Serialize)]
|
|
struct PersistedPoolMetaWithUnknownField {
|
|
version: u16,
|
|
pools: Vec<PersistedPoolStatus>,
|
|
unexpected: bool,
|
|
}
|
|
|
|
let payload = rmp_serde::to_vec_named(&PersistedPoolMetaWithUnknownField {
|
|
version: POOL_META_VERSION,
|
|
pools: Vec::new(),
|
|
unexpected: true,
|
|
})
|
|
.expect("pool metadata with unknown field should serialize");
|
|
|
|
let err = PoolMeta::decode_pool_meta_payload(POOL_META_VERSION, payload.as_slice())
|
|
.expect_err("unknown persisted pool metadata field should fail decode");
|
|
let rendered = err.to_string();
|
|
assert!(rendered.contains("PoolMeta v2 decode failed"));
|
|
assert!(rendered.contains("unknown field") || rendered.contains("missing field"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_decode_rejects_missing_critical_persisted_fields() {
|
|
#[derive(Serialize)]
|
|
struct PersistedPoolMetaWithoutPools {
|
|
version: u16,
|
|
}
|
|
|
|
let payload = rmp_serde::to_vec_named(&PersistedPoolMetaWithoutPools {
|
|
version: POOL_META_VERSION,
|
|
})
|
|
.expect("pool metadata without pools should serialize");
|
|
|
|
let err = PoolMeta::decode_pool_meta_payload(POOL_META_VERSION, payload.as_slice())
|
|
.expect_err("missing persisted pool metadata pools should fail decode");
|
|
assert!(err.to_string().contains("PoolMeta v2 decode failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_decode_rejects_unknown_decommission_fields() {
|
|
#[derive(Serialize)]
|
|
struct PersistedPoolStatusWithUnknownDecommission {
|
|
#[serde(rename = "id")]
|
|
id: usize,
|
|
#[serde(rename = "cmdline")]
|
|
cmd_line: String,
|
|
#[serde(rename = "lastUpdate", with = "time::serde::rfc3339")]
|
|
last_update: OffsetDateTime,
|
|
#[serde(rename = "decommissionInfo")]
|
|
decommission: Option<PersistedPoolDecommissionInfoWithUnknownField>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PersistedPoolDecommissionInfoWithUnknownField {
|
|
#[serde(rename = "startTime", with = "time::serde::rfc3339::option")]
|
|
start_time: Option<OffsetDateTime>,
|
|
#[serde(rename = "startSize")]
|
|
start_size: usize,
|
|
#[serde(rename = "totalSize")]
|
|
total_size: usize,
|
|
#[serde(rename = "currentSize")]
|
|
current_size: usize,
|
|
#[serde(rename = "complete")]
|
|
complete: bool,
|
|
#[serde(rename = "failed")]
|
|
failed: bool,
|
|
#[serde(rename = "canceled")]
|
|
canceled: bool,
|
|
#[serde(rename = "queuedBuckets")]
|
|
queued_buckets: Vec<String>,
|
|
#[serde(rename = "decommissionedBuckets")]
|
|
decommissioned_buckets: Vec<String>,
|
|
#[serde(rename = "bucket")]
|
|
bucket: String,
|
|
#[serde(rename = "prefix")]
|
|
prefix: String,
|
|
#[serde(rename = "object")]
|
|
object: String,
|
|
#[serde(rename = "objectsDecommissioned")]
|
|
items_decommissioned: usize,
|
|
#[serde(rename = "objectsDecommissionedFailed")]
|
|
items_decommission_failed: usize,
|
|
#[serde(rename = "bytesDecommissioned")]
|
|
bytes_done: usize,
|
|
#[serde(rename = "bytesDecommissionedFailed")]
|
|
bytes_failed: usize,
|
|
#[serde(rename = "unexpected")]
|
|
unexpected: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PersistedPoolMetaWithUnknownDecommission {
|
|
version: u16,
|
|
pools: Vec<PersistedPoolStatusWithUnknownDecommission>,
|
|
}
|
|
|
|
let start_time = OffsetDateTime::now_utc();
|
|
let payload = rmp_serde::to_vec_named(&PersistedPoolMetaWithUnknownDecommission {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![PersistedPoolStatusWithUnknownDecommission {
|
|
id: 0,
|
|
cmd_line: "/data/pool".to_string(),
|
|
last_update: start_time,
|
|
decommission: Some(PersistedPoolDecommissionInfoWithUnknownField {
|
|
start_time: Some(start_time),
|
|
start_size: 0,
|
|
total_size: 0,
|
|
current_size: 0,
|
|
complete: false,
|
|
failed: false,
|
|
canceled: false,
|
|
queued_buckets: Vec::new(),
|
|
decommissioned_buckets: Vec::new(),
|
|
bucket: String::new(),
|
|
prefix: String::new(),
|
|
object: String::new(),
|
|
items_decommissioned: 0,
|
|
items_decommission_failed: 0,
|
|
bytes_done: 0,
|
|
bytes_failed: 0,
|
|
unexpected: true,
|
|
}),
|
|
}],
|
|
})
|
|
.expect("pool metadata with unknown decommission field should serialize");
|
|
|
|
let err = PoolMeta::decode_pool_meta_payload(POOL_META_VERSION, payload.as_slice())
|
|
.expect_err("unknown persisted decommission metadata field should fail decode");
|
|
assert!(err.to_string().contains("PoolMeta v2 decode failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_decode_rejects_invalid_decommission_terminal_state() {
|
|
let start_time = OffsetDateTime::now_utc();
|
|
let persisted_meta = PersistedPoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![PersistedPoolStatus {
|
|
id: 1,
|
|
cmd_line: "/data/pool1/disk{1...4}".to_string(),
|
|
last_update: start_time,
|
|
decommission: Some(PersistedPoolDecommissionInfo {
|
|
start_time: Some(start_time),
|
|
complete: true,
|
|
failed: true,
|
|
canceled: false,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
};
|
|
|
|
let mut payload = Vec::new();
|
|
persisted_meta
|
|
.serialize(&mut Serializer::new(&mut payload))
|
|
.expect("persisted payload should serialize");
|
|
|
|
let err = PoolMeta::decode_pool_meta_payload(POOL_META_VERSION, &payload)
|
|
.expect_err("invalid terminal state should fail decode");
|
|
assert!(err.to_string().contains("invalid decommission terminal state"));
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_decode_rejects_invalid_legacy_decommission_terminal_state() {
|
|
let start_time = OffsetDateTime::now_utc();
|
|
let legacy_meta = LegacyPoolMeta {
|
|
version: POOL_META_V1_VERSION,
|
|
pools: vec![LegacyPoolStatus {
|
|
id: 1,
|
|
cmd_line: "/legacy/pool".to_string(),
|
|
last_update: start_time,
|
|
decommission: Some(LegacyPoolDecommissionInfo {
|
|
start_time: Some(start_time),
|
|
complete: true,
|
|
failed: false,
|
|
canceled: true,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
dont_save: false,
|
|
};
|
|
|
|
let mut payload = Vec::new();
|
|
legacy_meta
|
|
.serialize(&mut Serializer::new(&mut payload))
|
|
.expect("legacy payload should serialize");
|
|
|
|
let err = PoolMeta::decode_pool_meta_payload(POOL_META_V1_VERSION, &payload)
|
|
.expect_err("invalid legacy terminal state should fail decode");
|
|
assert!(err.to_string().contains("invalid decommission terminal state"));
|
|
}
|
|
}
|
|
|
|
// impl Fn(MetaCacheEntry) -> impl Future<Output = Result<(), Error>>
|
|
|
|
pub type ListCallback = Arc<dyn Fn(MetaCacheEntry) -> BoxFuture<'static, ()> + Send + Sync + 'static>;
|
|
|
|
const DECOMMISSION_ENTRY_QUEUE_HARD_CAP: usize = 256;
|
|
|
|
struct QueuedDecommissionEntry {
|
|
entry: MetaCacheEntry,
|
|
queue_permit: OwnedSemaphorePermit,
|
|
}
|
|
|
|
enum DecommissionEntryEnqueueResult {
|
|
Enqueued,
|
|
Canceled,
|
|
Closed,
|
|
}
|
|
|
|
fn decommission_entry_queue_capacity(worker_limit: usize) -> usize {
|
|
worker_limit.saturating_mul(2).clamp(1, DECOMMISSION_ENTRY_QUEUE_HARD_CAP)
|
|
}
|
|
|
|
async fn enqueue_decommission_entry(
|
|
rx: &CancellationToken,
|
|
outstanding: &Arc<Semaphore>,
|
|
tx: &mpsc::Sender<QueuedDecommissionEntry>,
|
|
entry: MetaCacheEntry,
|
|
) -> DecommissionEntryEnqueueResult {
|
|
let queue_permit = match tokio::select! {
|
|
biased;
|
|
_ = rx.cancelled() => return DecommissionEntryEnqueueResult::Canceled,
|
|
permit = outstanding.clone().acquire_owned() => permit,
|
|
} {
|
|
Ok(permit) => permit,
|
|
Err(_) => return DecommissionEntryEnqueueResult::Closed,
|
|
};
|
|
|
|
let queued = QueuedDecommissionEntry { entry, queue_permit };
|
|
tokio::select! {
|
|
biased;
|
|
_ = rx.cancelled() => DecommissionEntryEnqueueResult::Canceled,
|
|
result = tx.send(queued) => {
|
|
if result.is_ok() {
|
|
DecommissionEntryEnqueueResult::Enqueued
|
|
} else {
|
|
DecommissionEntryEnqueueResult::Closed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn drain_decommission_entry_queue(rx: &CancellationToken, outstanding: &Arc<Semaphore>, capacity: usize) -> bool {
|
|
let Ok(permits) = u32::try_from(capacity) else {
|
|
return true;
|
|
};
|
|
|
|
tokio::select! {
|
|
_ = rx.cancelled() => true,
|
|
result = outstanding.acquire_many(permits) => result.is_err(),
|
|
}
|
|
}
|
|
|
|
async fn record_decommission_entry_error(
|
|
entry_error: &Arc<tokio::sync::Mutex<Option<Error>>>,
|
|
rx: &CancellationToken,
|
|
err: Error,
|
|
) -> bool {
|
|
if rx.is_cancelled() {
|
|
return false;
|
|
}
|
|
|
|
let mut first_err = entry_error.lock().await;
|
|
if first_err.is_none() && !rx.is_cancelled() {
|
|
*first_err = Some(err);
|
|
rx.cancel();
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn ensure_decommission_unresolved_verification_disk_count(
|
|
expected: usize,
|
|
actual: usize,
|
|
pool_index: usize,
|
|
set_index: usize,
|
|
) -> Result<()> {
|
|
if actual == expected {
|
|
return Ok(());
|
|
}
|
|
Err(Error::other(format!(
|
|
"decommission unresolved-entry verification for pool {pool_index} set {set_index} requires all {expected} source disks, but only {actual} are online"
|
|
)))
|
|
}
|
|
|
|
impl SetDisks {
|
|
async fn decommission_unresolved_entry_absent_on_all_disks(
|
|
&self,
|
|
pool_index: usize,
|
|
entry: &DecommissionUnresolvedEntry,
|
|
) -> Result<bool> {
|
|
let (disks, _) = self.get_online_disks_with_healing(false).await;
|
|
ensure_decommission_unresolved_verification_disk_count(self.set_drive_count, disks.len(), pool_index, entry.set_index)?;
|
|
|
|
let object = encode_dir_object(&entry.object);
|
|
let reads = join_all(disks.iter().map(|disk| disk.read_xl(&entry.bucket, &object, false))).await;
|
|
let mut found = false;
|
|
for read in reads {
|
|
match read {
|
|
Ok(_) => found = true,
|
|
Err(DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::VolumeNotFound) => {}
|
|
Err(err) => {
|
|
return Err(Error::other(format!(
|
|
"decommission unresolved-entry verification failed for pool {pool_index} set {} path {}/{}: {err}",
|
|
entry.set_index, entry.bucket, entry.object
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(!found)
|
|
}
|
|
|
|
#[tracing::instrument(skip(self, store, rx, cb_func, entry_error))]
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn list_objects_to_decommission(
|
|
self: &Arc<Self>,
|
|
store: Arc<ECStore>,
|
|
rx: CancellationToken,
|
|
bucket_info: DecomBucketInfo,
|
|
cb_func: ListCallback,
|
|
entry_error: Arc<tokio::sync::Mutex<Option<Error>>>,
|
|
pool_index: usize,
|
|
set_index: usize,
|
|
source_generation: OffsetDateTime,
|
|
require_all_disks: bool,
|
|
) -> Result<()> {
|
|
let (disks, _) = self.get_online_disks_with_healing(false).await;
|
|
ensure_decommission_listing_disks_available(!disks.is_empty(), &bucket_info.name)?;
|
|
if require_all_disks {
|
|
ensure_decommission_unresolved_verification_disk_count(self.set_drive_count, disks.len(), pool_index, set_index)?;
|
|
}
|
|
|
|
let listing_quorum = self.set_drive_count.div_ceil(2);
|
|
|
|
let resolver = MetadataResolutionParams {
|
|
dir_quorum: listing_quorum,
|
|
obj_quorum: listing_quorum,
|
|
bucket: bucket_info.name.clone(),
|
|
..Default::default()
|
|
};
|
|
|
|
let cb1 = cb_func.clone();
|
|
let unresolved_error = entry_error.clone();
|
|
let unresolved_rx = rx.clone();
|
|
let unresolved_bucket = bucket_info.name.clone();
|
|
let unresolved_prefix = bucket_info.prefix.clone();
|
|
let unresolved_pool_index = pool_index;
|
|
let unresolved_set_index = set_index;
|
|
let unresolved_generation = source_generation;
|
|
let unresolved_store = store;
|
|
|
|
list_path_raw(
|
|
rx,
|
|
ListPathRawOptions {
|
|
disks: disks.iter().cloned().map(Some).collect(),
|
|
bucket: bucket_info.name.clone(),
|
|
path: bucket_info.prefix.clone(),
|
|
recursive: true,
|
|
min_disks: listing_quorum,
|
|
skip_walkdir_total_timeout: true,
|
|
walkdir_stall_timeout: Some(DECOMMISSION_BACKGROUND_WALKDIR_STALL_TIMEOUT),
|
|
agreed: Some(Box::new(move |entry: MetaCacheEntry| Box::pin(cb1(entry)))),
|
|
partial: Some(Box::new(move |entries: MetaCacheEntries, errs: &[Option<DiskError>]| {
|
|
let resolver = resolver.clone();
|
|
let cb_func = cb_func.clone();
|
|
let bucket = unresolved_bucket.clone();
|
|
let prefix = unresolved_prefix.clone();
|
|
let unresolved_error = unresolved_error.clone();
|
|
let unresolved_rx = unresolved_rx.clone();
|
|
let unresolved_store = unresolved_store.clone();
|
|
let pool_index = unresolved_pool_index;
|
|
let set_index = unresolved_set_index;
|
|
let source_generation = unresolved_generation;
|
|
let disk_error_count = errs.iter().flatten().count();
|
|
if unresolved_rx.is_cancelled() {
|
|
return Box::pin(async {});
|
|
}
|
|
|
|
match resolve_decommission_partial_listing_entry(
|
|
entries,
|
|
resolver,
|
|
&bucket,
|
|
&prefix,
|
|
disk_error_count,
|
|
pool_index,
|
|
set_index,
|
|
source_generation,
|
|
) {
|
|
Ok(entry) => {
|
|
warn!("decommission_pool: list_objects_to_decommission get {}", &entry.name);
|
|
Box::pin(async move {
|
|
cb_func(entry).await;
|
|
})
|
|
}
|
|
Err(unresolved_entry) => Box::pin(async move {
|
|
if unresolved_rx.is_cancelled() {
|
|
return;
|
|
}
|
|
let err = decommission_unresolved_listing_error(&unresolved_entry);
|
|
warn!(
|
|
event = EVENT_DECOMMISSION_BUCKET,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
|
bucket = %bucket,
|
|
prefix = %prefix,
|
|
state = "unresolved_entry",
|
|
error = %err,
|
|
"Decommission listing failed closed on unresolved metadata"
|
|
);
|
|
let err = match unresolved_store
|
|
.persist_decommission_unresolved_entry(pool_index, source_generation, unresolved_entry)
|
|
.await
|
|
{
|
|
Ok(()) => err,
|
|
Err(ledger_err) => Error::other(format!("{err}; {ledger_err}")),
|
|
};
|
|
record_decommission_entry_error(&unresolved_error, &unresolved_rx, err).await;
|
|
}),
|
|
}
|
|
})),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
if let Some(err) = entry_error.lock().await.clone() {
|
|
return Err(err);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn is_disk_online_state(state: &str) -> bool {
|
|
// The disk state strings are produced from rustfs_utils::os::get_drive_stats or DiskError::to_string().
|
|
// Conventionally, online is "ok"/"online" (may evolve). Be conservative:
|
|
// - Treat empty as unknown -> include it (to avoid dropping capacity).
|
|
// - Exclude explicit offline-ish states.
|
|
let s = state.trim().to_lowercase();
|
|
if s.is_empty() {
|
|
return true;
|
|
}
|
|
if s.contains("offline") {
|
|
return false;
|
|
}
|
|
if s.contains("not found") || s.contains("disk not found") {
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
fn decommission_physical_pool_capacity(
|
|
disks: &[rustfs_madmin::Disk],
|
|
pool_index: usize,
|
|
layout: DecommissionErasureLayout,
|
|
logical: PoolSpaceInfo,
|
|
) -> (usize, usize, usize) {
|
|
let width = layout.width();
|
|
let mut sets: HashMap<i32, Vec<&rustfs_madmin::Disk>> = HashMap::new();
|
|
let mut seen = HashSet::new();
|
|
for disk in disks {
|
|
let state = disk.state.trim().to_ascii_lowercase();
|
|
if disk.pool_index != pool_index as i32
|
|
|| disk.set_index < 0
|
|
|| disk.disk_index < 0
|
|
|| disk.disk_index as usize >= width
|
|
|| !matches!(state.as_str(), "ok" | "online")
|
|
{
|
|
continue;
|
|
}
|
|
let identity = (disk.set_index, disk.disk_index);
|
|
if seen.insert(identity) {
|
|
sets.entry(disk.set_index).or_default().push(disk);
|
|
}
|
|
}
|
|
|
|
let mut physical_total = 0usize;
|
|
let mut physical_free = 0usize;
|
|
let mut physical_used = 0usize;
|
|
let mut observed_set = false;
|
|
for set_disks in sets.values() {
|
|
if set_disks.is_empty() {
|
|
continue;
|
|
}
|
|
observed_set = true;
|
|
let min_total = set_disks
|
|
.iter()
|
|
.map(|disk| disk.total_space as usize)
|
|
.min()
|
|
.unwrap_or_default();
|
|
let min_free = set_disks
|
|
.iter()
|
|
.map(|disk| disk.available_space as usize)
|
|
.min()
|
|
.unwrap_or_default();
|
|
let max_used = set_disks
|
|
.iter()
|
|
.map(|disk| (disk.used_space as usize).max((disk.total_space as usize).saturating_sub(disk.available_space as usize)))
|
|
.max()
|
|
.unwrap_or_default();
|
|
physical_total = physical_total.saturating_add(min_total.saturating_mul(width));
|
|
physical_used = physical_used.saturating_add(max_used.saturating_mul(width));
|
|
if set_disks.len() >= width {
|
|
physical_free = physical_free.saturating_add(min_free.saturating_mul(width));
|
|
}
|
|
}
|
|
|
|
if observed_set {
|
|
return (physical_total, physical_free, physical_used);
|
|
}
|
|
|
|
let fallback_total = capacity_mul_div_ceil(logical.total, width, layout.data);
|
|
let fallback_used = capacity_mul_div_ceil(logical.used, width, layout.data)
|
|
.max(fallback_total.saturating_sub(capacity_mul_div_ceil(logical.free, width, layout.data)));
|
|
(fallback_total, 0, fallback_used)
|
|
}
|
|
|
|
#[deprecated(since = "0.1.0", note = "Use fallback_total_capacity_dedup instead")]
|
|
#[allow(
|
|
dead_code,
|
|
reason = "superseded by the replacement named in the comment at pools.rs:5071 (backlog#1823)"
|
|
)]
|
|
fn fallback_total_capacity(disks: &[rustfs_madmin::Disk]) -> usize {
|
|
fallback_total_capacity_dedup(disks)
|
|
}
|
|
|
|
#[deprecated(since = "0.1.0", note = "Use fallback_free_capacity_dedup instead")]
|
|
#[allow(
|
|
dead_code,
|
|
reason = "superseded by the replacement named in the comment at pools.rs:5071 (backlog#1823)"
|
|
)]
|
|
fn fallback_free_capacity(disks: &[rustfs_madmin::Disk]) -> usize {
|
|
fallback_free_capacity_dedup(disks)
|
|
}
|
|
|
|
pub fn get_total_usable_capacity(disks: &[rustfs_madmin::Disk], info: &rustfs_madmin::StorageInfo) -> usize {
|
|
// If backend info is missing or inconsistent, do a safe fallback to avoid reporting nonsense.
|
|
if info.backend.standard_sc_data.is_empty() {
|
|
return fallback_total_capacity_dedup(disks);
|
|
}
|
|
let mut capacity = 0usize;
|
|
let mut matched_any = false;
|
|
let mut counted_disks: HashSet<String> = HashSet::new();
|
|
|
|
for disk in disks.iter() {
|
|
if disk.pool_index < 0 {
|
|
continue;
|
|
}
|
|
let pool_idx = disk.pool_index as usize;
|
|
if info.backend.standard_sc_data.len() <= pool_idx {
|
|
continue;
|
|
}
|
|
|
|
let usable_disks_per_set = info.backend.standard_sc_data[pool_idx];
|
|
if usable_disks_per_set == 0 {
|
|
continue;
|
|
}
|
|
|
|
if (disk.disk_index as usize) < usable_disks_per_set {
|
|
// 🔧 Generate a unique identity using a combination of fields
|
|
let disk_key = format!(
|
|
"{}|{}|p{}s{}d{}",
|
|
disk.endpoint, // Node address
|
|
disk.drive_path, // mount path
|
|
disk.pool_index, // Pool index
|
|
disk.set_index, // Collection index
|
|
disk.disk_index // Disk index
|
|
);
|
|
debug!("get_total_usable_capacity disk_key: {}", disk_key);
|
|
// 🔧 Only disks that have not been counted are counted towards capacity
|
|
if counted_disks.insert(disk_key) {
|
|
matched_any = true;
|
|
capacity += disk.total_space as usize;
|
|
} else {
|
|
// Log duplicate disks: this likely indicates a configuration issue and should always be visible.
|
|
warn!(
|
|
"Duplicate disk detected in capacity calculation: {} at {}",
|
|
disk.endpoint, disk.drive_path
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if matched_any {
|
|
capacity
|
|
} else {
|
|
// Even if standard_sc_data exists, it might not match disk indexes due to upstream bugs.
|
|
// Fallback to summing all online disks to prevent under-reporting.
|
|
fallback_total_capacity_dedup(disks)
|
|
}
|
|
}
|
|
|
|
pub fn get_total_usable_capacity_free(disks: &[rustfs_madmin::Disk], info: &rustfs_madmin::StorageInfo) -> usize {
|
|
if info.backend.standard_sc_data.is_empty() {
|
|
return fallback_free_capacity_dedup(disks);
|
|
}
|
|
|
|
let mut capacity = 0usize;
|
|
let mut matched_any = false;
|
|
let mut counted_disks: HashSet<String> = HashSet::new();
|
|
|
|
for disk in disks.iter() {
|
|
if disk.pool_index < 0 {
|
|
continue;
|
|
}
|
|
let pool_idx = disk.pool_index as usize;
|
|
if info.backend.standard_sc_data.len() <= pool_idx {
|
|
continue;
|
|
}
|
|
|
|
let usable_disks_per_set = info.backend.standard_sc_data[pool_idx];
|
|
if usable_disks_per_set == 0 {
|
|
continue;
|
|
}
|
|
|
|
if (disk.disk_index as usize) < usable_disks_per_set {
|
|
let disk_key = format!(
|
|
"{}|{}|p{}s{}d{}",
|
|
disk.endpoint, disk.drive_path, disk.pool_index, disk.set_index, disk.disk_index
|
|
);
|
|
|
|
if counted_disks.insert(disk_key) {
|
|
matched_any = true;
|
|
capacity += disk.available_space as usize;
|
|
}
|
|
}
|
|
}
|
|
|
|
if matched_any {
|
|
capacity
|
|
} else {
|
|
fallback_free_capacity_dedup(disks)
|
|
}
|
|
}
|
|
|
|
/// Total fallback capacity calculation with deweight
|
|
///
|
|
/// Replace original function: fallback_total_capacity()
|
|
pub(crate) fn fallback_total_capacity_dedup(disks: &[rustfs_madmin::Disk]) -> usize {
|
|
let mut counted_disks: HashSet<String> = HashSet::new();
|
|
let mut total = 0usize;
|
|
|
|
for disk in disks.iter() {
|
|
// Only online disks are counted
|
|
if !is_disk_online_state(&disk.state) {
|
|
continue;
|
|
}
|
|
|
|
// Use endpoint + drive_path as a unique identifier
|
|
let disk_key = format!("{}|{}", disk.endpoint, disk.drive_path);
|
|
|
|
// Capacity is counted only when the disk is encountered for the first time
|
|
if counted_disks.insert(disk_key) {
|
|
total += disk.total_space as usize;
|
|
}
|
|
}
|
|
|
|
total
|
|
}
|
|
|
|
/// Remove the heavy fallback idle capacity calculation
|
|
///
|
|
/// Replace original function: fallback_free_capacity()
|
|
pub(crate) fn fallback_free_capacity_dedup(disks: &[rustfs_madmin::Disk]) -> usize {
|
|
let mut counted_disks: HashSet<String> = HashSet::new();
|
|
let mut total = 0usize;
|
|
|
|
for disk in disks.iter() {
|
|
if !is_disk_online_state(&disk.state) {
|
|
continue;
|
|
}
|
|
|
|
let disk_key = format!("{}|{}", disk.endpoint, disk.drive_path);
|
|
|
|
if counted_disks.insert(disk_key) {
|
|
total += disk.available_space as usize;
|
|
}
|
|
}
|
|
|
|
total
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod pools_tests {
|
|
use super::DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF;
|
|
use super::persist_v3_pool_meta_for_test;
|
|
use super::record_decommission_entry_error;
|
|
use super::resolve_decommission_listing_error;
|
|
use super::resolve_decommission_partial_listing_entry;
|
|
use super::{
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION, DECOMMISSION_CAPACITY_MODEL_VERSION, DECOMMISSION_CAPACITY_RELEASE_CANCELED,
|
|
DECOMMISSION_CAPACITY_RELEASE_COMPLETED, DECOMMISSION_CAPACITY_RELEASE_FAILED, DECOMMISSION_CAPACITY_RESERVATION_TTL,
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION, DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE,
|
|
DECOMMISSION_ENTRY_CONCURRENCY_DEFAULT_CAP, DECOMMISSION_ENTRY_CONCURRENCY_HARD_CAP, DECOMMISSION_ENTRY_QUEUE_HARD_CAP,
|
|
DECOMMISSION_META_PREFIXES, DECOMMISSION_PROGRESS_SAVE_INTERVAL, DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
|
|
DECOMMISSION_SOURCE_CHANGED_EXHAUSTION_LIMIT, DecomBucketInfo, DecommissionCanceler, DecommissionCapacityTarget,
|
|
DecommissionDurableIlmReceipt, DecommissionEntryEnqueueResult, DecommissionErasureLayout, DecommissionPoolCapacityInfo,
|
|
DecommissionStartPoolState, DecommissionTargetConsumption, DecommissionTerminalState, DecommissionUnresolvedEntry,
|
|
ListCallback, POOL_META_GENERATION_VERSION, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_V1_VERSION,
|
|
POOL_META_VERSION, PoolDecommissionInfo, PoolMeta, PoolMetaCasToken, PoolMetaPersistenceFence, PoolSpaceInfo, PoolStatus,
|
|
QueuedDecommissionEntry, REBAL_META_NAME, acquire_pool_rebalance_activation_locks, apply_decommission_status_space_info,
|
|
await_decommission_worker, bind_decommission_cancelers, bind_missing_decommission_cancelers,
|
|
build_decommission_capacity_reservation, build_decommission_capacity_reservation_with_model,
|
|
cancel_decommission_canceler, clamp_decommission_entry_concurrency, classify_decommission_terminal_state,
|
|
count_decommission_item, decommission_cancel_signal_result, decommission_durable_ilm_receipt_path,
|
|
decommission_durable_ilm_receipt_run_prefix, decommission_durable_ilm_receipt_run_token,
|
|
decommission_entry_queue_capacity, decommission_item_size, decommission_meta_bucket_options,
|
|
decommission_physical_pool_capacity, decommission_retry_backoff_delay, decommission_start_pool_state,
|
|
decommission_unresolved_listing_error, dedup_indices, default_decommission_bucket_concurrency,
|
|
default_decommission_entry_concurrency, drain_decommission_entry_queue, enqueue_decommission_entry,
|
|
ensure_decommission_cancel_allowed, ensure_decommission_capacity_reservations_available,
|
|
ensure_decommission_clear_allowed, ensure_decommission_generation, ensure_decommission_listing_disks_available,
|
|
ensure_decommission_not_rebalancing, ensure_decommission_start_allowed, ensure_decommission_start_keeps_active_pool,
|
|
ensure_decommission_start_local_leader, ensure_decommission_start_pool_states,
|
|
ensure_decommission_start_rebalance_meta_allowed, ensure_decommission_start_target_capacity,
|
|
ensure_decommission_terminal_operation_supported, ensure_decommission_unresolved_verification_disk_count,
|
|
ensure_local_decommission_pool_leaders, ensure_pool_meta_write_fence, ensure_valid_decommission_pool_index, get_by_index,
|
|
guard_decommission_cancelers, has_active_decommission_canceler, is_decommission_active, is_decommission_cancel_requested,
|
|
load_decommission_entry_versions, local_decommission_queue_prefix, mark_decommission_bucket_done,
|
|
merge_decommission_durable_ilm_receipts, merge_pool_meta_updates_for_save, merge_pool_status_refresh,
|
|
missing_decommission_worker_prefix, next_decommission_capacity_generation, observe_decommission_terminal_reload_result,
|
|
pool_meta_has_active_decommission, publish_pool_meta_updates, read_pool_meta_replica,
|
|
reconcile_decommission_meta_buckets, reconcile_decommission_unresolved_entries_for_completion,
|
|
record_decommission_unresolved_entry, recover_decommission_capacity_reservations,
|
|
renew_decommission_capacity_reservation, require_decommission_store, reserve_decommission_start_cancelers,
|
|
reserve_decommission_start_target_capacity, resolve_decommission_bucket_state,
|
|
resolve_decommission_check_after_list_result, resolve_decommission_entry_cleanup_delete_result,
|
|
resolve_decommission_entry_exact_versions, resolve_decommission_entry_reload_result,
|
|
resolve_decommission_listing_worker_result, resolve_decommission_optional_bucket_config_result,
|
|
resolve_decommission_pool_meta_reload_result, resolve_decommission_preflight_heal_result,
|
|
resolve_decommission_progress_save_result, resolve_decommission_terminal_mark_after_error_result,
|
|
resolve_decommission_terminal_mark_result, resolve_decommission_update_after_result,
|
|
resolve_start_decommission_pool_meta_reload_result, resumable_decommission_queue_indices,
|
|
rollback_start_decommission_pool_meta, run_decommission_buckets_bounded, run_decommission_listing_with_retry,
|
|
run_decommission_listing_with_retry_and_drain, run_decommission_phases, run_decommission_side_effect,
|
|
save_pool_meta_object_cas, select_decommission_capacity_model, should_cleanup_decommission_source_entry,
|
|
should_continue_decommission_queue, should_count_decommission_version_complete,
|
|
should_fail_decommission_pool_after_exhausted_source_changed, should_preserve_decommission_canceled_state,
|
|
should_reject_decommission_cancel_as_terminal, should_retry_decommission_cancel_reload,
|
|
should_retry_decommission_listing, should_skip_canceled_decommission_routine, spawn_decommission_index_cancelers,
|
|
split_decommission_buckets, take_and_cancel_decommission_canceler, take_decommission_canceler,
|
|
track_decommission_current_object, track_decommission_current_object_stage, update_decommission_for_operation,
|
|
validate_start_decommission_request, wait_decommission_retry_backoff, wait_decommission_worker_drain,
|
|
with_decommission_entry_context,
|
|
};
|
|
use super::{
|
|
DecommissionCapacityOwner, DecommissionCapacityReleaseProof, DecommissionCapacityReservation,
|
|
DecommissionCapacityTemporaryMutation, decommission_capacity_mutation_id, ensure_decommission_target_owner_admission,
|
|
ensure_exact_delete_capacity_namespace_fences, ensure_external_decommission_target_admission,
|
|
is_decommission_capacity_blocked_error, plan_exact_delete_capacity_reconciliations,
|
|
record_decommission_target_consumption, release_decommission_target_inflight, reserve_decommission_target_pending,
|
|
resolve_decommission_target_pending, set_decommission_capacity_info_overrides_for_test,
|
|
};
|
|
use crate::bucket::lifecycle::{
|
|
DurableIlmRecordCheckpoint,
|
|
bucket_lifecycle_ops::{ManualTransitionQueueSnapshot, ManualTransitionRunOptions},
|
|
manual_transition_job::{ManualTransitionJobRecord, manual_transition_job_record_object_name},
|
|
validate_durable_ilm_record,
|
|
};
|
|
use crate::bucket::metadata_sys;
|
|
use crate::data_movement;
|
|
use crate::disk::{STORAGE_FORMAT_FILE, endpoint::Endpoint};
|
|
use crate::error::{Error, StorageError};
|
|
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
|
use crate::object_api::{ObjectInfo, ObjectOptions};
|
|
use crate::runtime::instance::InstanceContext;
|
|
use crate::services::rebalance::{RebalStatus, RebalanceInfo, RebalanceMeta, RebalanceStats};
|
|
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
|
|
use crate::storage_api_contracts::object::HTTPPreconditions;
|
|
use crate::storage_api_contracts::{object::ObjectIO, range::HTTPRangeSpec};
|
|
use crate::store::ECStore;
|
|
use byteorder::{ByteOrder, LittleEndian};
|
|
use rmp_serde::Serializer;
|
|
use rustfs_filemeta::{FileInfo, FileInfoVersions, MetaCacheEntry, ObjectPartInfo};
|
|
use rustfs_filemeta::{MetaCacheEntries, MetadataResolutionParams};
|
|
use rustfs_lock::{GlobalLockManager, LocalClient, LockRequest, LockType, NamespaceLock, ObjectKey};
|
|
use rustfs_rio::Index;
|
|
use serde::Serialize;
|
|
use std::future::Future;
|
|
use std::io::Cursor;
|
|
use std::sync::{
|
|
Arc, Mutex as StdMutex,
|
|
atomic::{AtomicBool, AtomicUsize, Ordering},
|
|
};
|
|
use std::task::{Context, Poll};
|
|
use std::time::Duration as StdDuration;
|
|
use time::{Duration, OffsetDateTime};
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::sync::Semaphore;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
#[derive(Debug)]
|
|
struct ActivationLockRecorder {
|
|
lock_manager: Arc<rustfs_lock::GlobalLockManager>,
|
|
owner: &'static str,
|
|
resources: StdMutex<Vec<String>>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl crate::storage_api_contracts::namespace::NamespaceLocking for ActivationLockRecorder {
|
|
type Error = Error;
|
|
type NamespaceLock = rustfs_lock::NamespaceLockWrapper;
|
|
|
|
async fn new_ns_lock(&self, bucket: &str, object: &str) -> crate::error::Result<Self::NamespaceLock> {
|
|
self.resources
|
|
.lock()
|
|
.expect("activation lock recorder should not be poisoned")
|
|
.push(object.to_string());
|
|
Ok(rustfs_lock::NamespaceLockWrapper::new(
|
|
rustfs_lock::NamespaceLock::with_local_manager(
|
|
"activation-lock-test".to_string(),
|
|
Arc::clone(&self.lock_manager),
|
|
),
|
|
rustfs_lock::ObjectKey::new(bucket, object),
|
|
self.owner.to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
fn noop_decommission_list_callback() -> ListCallback {
|
|
Arc::new(|_| Box::pin(async {}))
|
|
}
|
|
|
|
fn decommission_worker_test_store(pool_meta: PoolMeta, cancelers: Vec<Option<DecommissionCanceler>>) -> Arc<ECStore> {
|
|
let ctx = Arc::new(InstanceContext::new());
|
|
let endpoint_pools = EndpointServerPools::default();
|
|
Arc::new(ECStore {
|
|
id: uuid::Uuid::new_v4(),
|
|
disk_map: std::collections::HashMap::new(),
|
|
pools: Vec::new(),
|
|
peer_sys: crate::cluster::rpc::S3PeerSys::new_with_instance_ctx(&endpoint_pools, ctx.clone()),
|
|
pool_meta: tokio::sync::RwLock::new(pool_meta),
|
|
rebalance_meta: tokio::sync::RwLock::new(None),
|
|
decommission_cancelers: tokio::sync::RwLock::new(cancelers),
|
|
start_gate: tokio::sync::Mutex::new(()),
|
|
pool_meta_save_gate: tokio::sync::Mutex::new(super::PoolMetaWriteState::for_test_bootstrap()),
|
|
ctx,
|
|
bucket_fence_registry: Arc::default(),
|
|
})
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PartialPoolMetaWriteStorage {
|
|
fail_write: bool,
|
|
fail_after_first_write: bool,
|
|
pending_write: bool,
|
|
write_started: tokio::sync::Notify,
|
|
wrote: AtomicBool,
|
|
revision: AtomicUsize,
|
|
stored: StdMutex<Option<(Vec<u8>, String)>>,
|
|
identity: StdMutex<Option<(Vec<u8>, String)>>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl ObjectIO for PartialPoolMetaWriteStorage {
|
|
type Error = Error;
|
|
type RangeSpec = HTTPRangeSpec;
|
|
type HeaderMap = http::HeaderMap;
|
|
type ObjectOptions = crate::object_api::ObjectOptions;
|
|
type ObjectInfo = crate::object_api::ObjectInfo;
|
|
type GetObjectReader = crate::object_api::GetObjectReader;
|
|
type PutObjectReader = crate::object_api::PutObjReader;
|
|
|
|
async fn get_object_reader(
|
|
&self,
|
|
bucket: &str,
|
|
object: &str,
|
|
_range: Option<Self::RangeSpec>,
|
|
_h: Self::HeaderMap,
|
|
_opts: &Self::ObjectOptions,
|
|
) -> std::result::Result<Self::GetObjectReader, Error> {
|
|
let stored = if object == POOL_META_IDENTITY_NAME {
|
|
&self.identity
|
|
} else {
|
|
&self.stored
|
|
};
|
|
let Some((data, etag)) = stored
|
|
.lock()
|
|
.expect("pool metadata test storage should not be poisoned")
|
|
.clone()
|
|
else {
|
|
return Err(Error::FileNotFound);
|
|
};
|
|
Ok(crate::object_api::GetObjectReader {
|
|
stream: Box::new(Cursor::new(data.clone())),
|
|
object_info: crate::object_api::ObjectInfo {
|
|
bucket: bucket.to_string(),
|
|
name: object.to_string(),
|
|
size: data.len() as i64,
|
|
etag: Some(etag),
|
|
..Default::default()
|
|
},
|
|
buffered_body: None,
|
|
body_source: Default::default(),
|
|
})
|
|
}
|
|
|
|
async fn put_object(
|
|
&self,
|
|
_bucket: &str,
|
|
object: &str,
|
|
data: &mut Self::PutObjectReader,
|
|
opts: &Self::ObjectOptions,
|
|
) -> std::result::Result<Self::ObjectInfo, Error> {
|
|
self.write_started.notify_one();
|
|
if self.pending_write {
|
|
std::future::pending().await
|
|
}
|
|
if object == POOL_META_NAME
|
|
&& (self.fail_write || (self.fail_after_first_write && self.revision.load(Ordering::SeqCst) > 0))
|
|
{
|
|
return Err(Error::Timeout);
|
|
}
|
|
let stored = if object == POOL_META_IDENTITY_NAME {
|
|
&self.identity
|
|
} else {
|
|
&self.stored
|
|
};
|
|
let current_etag = stored
|
|
.lock()
|
|
.expect("pool metadata test storage should not be poisoned")
|
|
.as_ref()
|
|
.map(|(_, etag)| etag.clone());
|
|
if opts
|
|
.http_preconditions
|
|
.as_ref()
|
|
.and_then(HTTPPreconditions::if_none_match_value)
|
|
== Some("*")
|
|
&& current_etag.is_some()
|
|
{
|
|
return Err(Error::PreconditionFailed);
|
|
}
|
|
if let Some(expected) = opts.http_preconditions.as_ref().and_then(HTTPPreconditions::if_match_value)
|
|
&& current_etag.as_deref() != Some(expected)
|
|
{
|
|
return Err(Error::PreconditionFailed);
|
|
}
|
|
let mut payload = Vec::new();
|
|
data.stream.read_to_end(&mut payload).await?;
|
|
let etag = format!("pool-meta-test-{}", self.revision.fetch_add(1, Ordering::SeqCst) + 1);
|
|
*stored.lock().expect("pool metadata test storage should not be poisoned") = Some((payload, etag.clone()));
|
|
self.wrote.store(true, Ordering::SeqCst);
|
|
Ok(crate::object_api::ObjectInfo {
|
|
etag: Some(etag),
|
|
..Default::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_pool_meta_cas_deterministically_rejects_stale_writer() {
|
|
let storage = Arc::new(PartialPoolMetaWriteStorage {
|
|
fail_write: false,
|
|
fail_after_first_write: false,
|
|
pending_write: false,
|
|
write_started: tokio::sync::Notify::new(),
|
|
wrote: AtomicBool::new(false),
|
|
revision: AtomicUsize::new(0),
|
|
stored: StdMutex::new(None),
|
|
identity: StdMutex::new(None),
|
|
});
|
|
let stale_token = read_pool_meta_replica(storage.clone(), true).await.cas;
|
|
assert!(matches!(&stale_token, PoolMetaCasToken::Missing));
|
|
let winner = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None)],
|
|
..Default::default()
|
|
}
|
|
.encode_config_data_for_test()
|
|
.expect("winning pool metadata should encode");
|
|
let stale = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
}
|
|
.encode_config_data_for_test()
|
|
.expect("stale pool metadata should encode");
|
|
let fence = PoolMetaPersistenceFence::Distributed(None);
|
|
|
|
save_pool_meta_object_cas(storage.clone(), POOL_META_NAME, winner.clone(), &stale_token, &fence, "prepare_cas")
|
|
.await
|
|
.expect("the first writer should create pool metadata");
|
|
let err = save_pool_meta_object_cas(storage.clone(), POOL_META_NAME, stale, &stale_token, &fence, "prepare_cas")
|
|
.await
|
|
.expect_err("the second writer must not reuse the stale missing-object revision");
|
|
|
|
assert_eq!(err, Error::PreconditionFailed);
|
|
let stored = storage
|
|
.stored
|
|
.lock()
|
|
.expect("pool metadata test storage should not be poisoned")
|
|
.as_ref()
|
|
.map(|(data, _)| data.clone())
|
|
.expect("the winning pool metadata should remain stored");
|
|
assert_eq!(stored, winner);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_pool_meta_v3_single_replica_commit_failure_recovers_on_restart() {
|
|
let committed_replica = Arc::new(PartialPoolMetaWriteStorage {
|
|
fail_write: false,
|
|
fail_after_first_write: false,
|
|
pending_write: false,
|
|
write_started: tokio::sync::Notify::new(),
|
|
wrote: AtomicBool::new(false),
|
|
revision: AtomicUsize::new(0),
|
|
stored: StdMutex::new(None),
|
|
identity: StdMutex::new(None),
|
|
});
|
|
let pending_replica = Arc::new(PartialPoolMetaWriteStorage {
|
|
fail_write: false,
|
|
fail_after_first_write: true,
|
|
pending_write: false,
|
|
write_started: tokio::sync::Notify::new(),
|
|
wrote: AtomicBool::new(false),
|
|
revision: AtomicUsize::new(0),
|
|
stored: StdMutex::new(None),
|
|
identity: StdMutex::new(None),
|
|
});
|
|
let cluster_id = uuid::Uuid::new_v4();
|
|
let fresh_bootstrap_nonce = uuid::Uuid::new_v4();
|
|
let identity = super::encode_pool_meta_identity(super::PersistedPoolMetaIdentity {
|
|
version: super::POOL_META_IDENTITY_VERSION,
|
|
cluster_id,
|
|
epoch: super::POOL_META_INITIAL_EPOCH,
|
|
initialized: false,
|
|
fresh_bootstrap_nonce: Some(fresh_bootstrap_nonce),
|
|
})
|
|
.expect("pending bootstrap identity should encode");
|
|
*committed_replica
|
|
.identity
|
|
.lock()
|
|
.expect("identity storage should not be poisoned") = Some((identity.clone(), "identity-0".to_string()));
|
|
*pending_replica
|
|
.identity
|
|
.lock()
|
|
.expect("identity storage should not be poisoned") = Some((identity, "identity-0".to_string()));
|
|
let mut write_state = super::PoolMetaWriteState::for_startup(cluster_id, true);
|
|
let snapshot = PoolMeta {
|
|
version: super::POOL_META_GENERATION_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
queued: true,
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
|
|
snapshot
|
|
.save_no_lock_observing(vec![committed_replica.clone(), pending_replica.clone()], &mut write_state)
|
|
.await
|
|
.expect("one committed replica should durably complete the V3 transaction");
|
|
|
|
let mut restarted = PoolMeta::default();
|
|
let replica_state = restarted
|
|
.load_no_lock_from_replicas(vec![committed_replica, pending_replica.clone()])
|
|
.await
|
|
.expect("restart should select the committed generation over a pending replica");
|
|
assert_eq!(restarted.version, super::POOL_META_GENERATION_VERSION);
|
|
assert!(restarted.pools[0].decommission.as_ref().is_some_and(|info| info.queued));
|
|
assert!(replica_state.needs_repair);
|
|
|
|
let mut pending_only = PoolMeta::default();
|
|
pending_only
|
|
.load_no_lock_from_replicas(vec![pending_replica])
|
|
.await
|
|
.expect("a prepare-only replica should expose the embedded predecessor");
|
|
assert!(pending_only.pools.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_partial_pool_meta_save_failure_blocks_following_side_effect() {
|
|
let store = decommission_worker_test_store(PoolMeta::default(), Vec::new());
|
|
let committed = Arc::new(PartialPoolMetaWriteStorage {
|
|
fail_write: false,
|
|
fail_after_first_write: false,
|
|
pending_write: false,
|
|
write_started: tokio::sync::Notify::new(),
|
|
wrote: AtomicBool::new(false),
|
|
revision: AtomicUsize::new(0),
|
|
stored: StdMutex::new(None),
|
|
identity: StdMutex::new(None),
|
|
});
|
|
let failed = Arc::new(PartialPoolMetaWriteStorage {
|
|
fail_write: true,
|
|
fail_after_first_write: false,
|
|
pending_write: false,
|
|
write_started: tokio::sync::Notify::new(),
|
|
wrote: AtomicBool::new(false),
|
|
revision: AtomicUsize::new(0),
|
|
stored: StdMutex::new(None),
|
|
identity: StdMutex::new(None),
|
|
});
|
|
let snapshot = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None)],
|
|
..Default::default()
|
|
};
|
|
|
|
{
|
|
let mut save_guard = store.pool_meta_save_gate.lock().await;
|
|
snapshot
|
|
.save_no_lock_observing(vec![committed.clone(), failed], &mut save_guard)
|
|
.await
|
|
.expect_err("the second replica write should fail after the first commits");
|
|
}
|
|
assert!(committed.wrote.load(Ordering::SeqCst));
|
|
|
|
let ran = Arc::new(AtomicBool::new(false));
|
|
let ran_by_operation = ran.clone();
|
|
let movement_gate = store.ctx.data_movement_operation_gate();
|
|
let result: std::result::Result<(), Error> = store
|
|
.run_guarded_decommission_side_effect(&CancellationToken::new(), &movement_gate, move || async move {
|
|
ran_by_operation.store(true, Ordering::SeqCst);
|
|
Ok(())
|
|
})
|
|
.await;
|
|
|
|
assert!(result.is_err(), "a partial pool metadata save must latch the sticky safety gate");
|
|
assert!(!ran.load(Ordering::SeqCst), "the side effect must not run after a partial save");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cancelled_pool_meta_save_blocks_following_side_effect() {
|
|
let store = decommission_worker_test_store(PoolMeta::default(), Vec::new());
|
|
let committed = Arc::new(PartialPoolMetaWriteStorage {
|
|
fail_write: false,
|
|
fail_after_first_write: false,
|
|
pending_write: false,
|
|
write_started: tokio::sync::Notify::new(),
|
|
wrote: AtomicBool::new(false),
|
|
revision: AtomicUsize::new(0),
|
|
stored: StdMutex::new(None),
|
|
identity: StdMutex::new(None),
|
|
});
|
|
let pending = Arc::new(PartialPoolMetaWriteStorage {
|
|
fail_write: false,
|
|
fail_after_first_write: false,
|
|
pending_write: true,
|
|
write_started: tokio::sync::Notify::new(),
|
|
wrote: AtomicBool::new(false),
|
|
revision: AtomicUsize::new(0),
|
|
stored: StdMutex::new(None),
|
|
identity: StdMutex::new(None),
|
|
});
|
|
let snapshot = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None)],
|
|
..Default::default()
|
|
};
|
|
let save_store = store.clone();
|
|
let save_committed = committed.clone();
|
|
let save_pending = pending.clone();
|
|
let save_task = tokio::spawn(async move {
|
|
let mut save_guard = save_store.pool_meta_save_gate.lock().await;
|
|
snapshot
|
|
.save_no_lock_observing(vec![save_committed, save_pending], &mut save_guard)
|
|
.await
|
|
});
|
|
|
|
tokio::time::timeout(StdDuration::from_secs(1), pending.write_started.notified())
|
|
.await
|
|
.expect("the second replica write should start");
|
|
assert!(committed.wrote.load(Ordering::SeqCst));
|
|
save_task.abort();
|
|
assert!(
|
|
save_task.await.expect_err("the save task should be cancelled").is_cancelled(),
|
|
"the pending replica write should be aborted"
|
|
);
|
|
|
|
{
|
|
let save_guard = store.pool_meta_save_gate.lock().await;
|
|
save_guard
|
|
.ensure_write_safe("cancelled pool metadata save")
|
|
.expect_err("a cancelled replica update must latch the sticky safety gate");
|
|
}
|
|
|
|
let ran = Arc::new(AtomicBool::new(false));
|
|
let ran_by_operation = ran.clone();
|
|
let movement_gate = store.ctx.data_movement_operation_gate();
|
|
let result: std::result::Result<(), Error> = store
|
|
.run_guarded_decommission_side_effect(&CancellationToken::new(), &movement_gate, move || async move {
|
|
ran_by_operation.store(true, Ordering::SeqCst);
|
|
Ok(())
|
|
})
|
|
.await;
|
|
|
|
assert!(result.is_err(), "a cancelled pool metadata save must block following side effects");
|
|
assert!(!ran.load(Ordering::SeqCst), "the side effect must not run after a cancelled save");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cancelled_pool_meta_publish_keeps_write_gate_blocked() {
|
|
let store = decommission_worker_test_store(PoolMeta::default(), Vec::new());
|
|
let committed = Arc::new(PartialPoolMetaWriteStorage {
|
|
fail_write: false,
|
|
fail_after_first_write: false,
|
|
pending_write: false,
|
|
write_started: tokio::sync::Notify::new(),
|
|
wrote: AtomicBool::new(false),
|
|
revision: AtomicUsize::new(0),
|
|
stored: StdMutex::new(None),
|
|
identity: StdMutex::new(None),
|
|
});
|
|
let snapshot = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None)],
|
|
..Default::default()
|
|
};
|
|
let publish_started = Arc::new(tokio::sync::Notify::new());
|
|
let publish_release = Arc::new(tokio::sync::Notify::new());
|
|
let save_store = store.clone();
|
|
let save_committed = committed.clone();
|
|
let task_publish_started = publish_started.clone();
|
|
let task_publish_release = publish_release.clone();
|
|
let save_task = tokio::spawn(async move {
|
|
let mut save_guard = save_store.pool_meta_save_gate.lock().await;
|
|
let outcome = snapshot
|
|
.save_no_lock_armed(vec![save_committed], &mut save_guard, None, &[0])
|
|
.await?;
|
|
task_publish_started.notify_one();
|
|
task_publish_release.notified().await;
|
|
outcome.disarm();
|
|
Ok::<(), Error>(())
|
|
});
|
|
|
|
tokio::time::timeout(StdDuration::from_secs(1), publish_started.notified())
|
|
.await
|
|
.expect("the replica save should complete before publication");
|
|
assert!(committed.wrote.load(Ordering::SeqCst));
|
|
save_task.abort();
|
|
assert!(
|
|
save_task
|
|
.await
|
|
.expect_err("the publication task should be cancelled")
|
|
.is_cancelled(),
|
|
"the task should be aborted while publication is pending"
|
|
);
|
|
|
|
let save_guard = store.pool_meta_save_gate.lock().await;
|
|
save_guard
|
|
.ensure_write_safe("cancelled pool metadata publication")
|
|
.expect_err("cancellation after replica save but before publication must keep writes blocked");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_lost_pool_meta_fence_rejects_replica_write() {
|
|
let client = Arc::new(LocalClient::with_manager(Arc::new(GlobalLockManager::new())));
|
|
let lock = NamespaceLock::with_clients_and_quorum("pool-meta-fence-loss".to_string(), vec![client], 1);
|
|
let request = LockRequest::new(
|
|
ObjectKey::new(super::RUSTFS_META_BUCKET, super::POOL_META_NAME),
|
|
LockType::Exclusive,
|
|
"stale-writer",
|
|
)
|
|
.with_acquire_timeout(StdDuration::from_secs(1))
|
|
.with_ttl(StdDuration::from_millis(50))
|
|
.with_refresh_interval(StdDuration::from_millis(50));
|
|
let guard = lock
|
|
.acquire_guard(&request)
|
|
.await
|
|
.expect("pool metadata fence acquisition should not error")
|
|
.expect("the stale writer should acquire the pool metadata fence");
|
|
tokio::time::timeout(StdDuration::from_secs(2), guard.lock_lost_notified())
|
|
.await
|
|
.expect("the stale writer lease should expire");
|
|
|
|
let storage = Arc::new(PartialPoolMetaWriteStorage {
|
|
fail_write: false,
|
|
fail_after_first_write: false,
|
|
pending_write: false,
|
|
write_started: tokio::sync::Notify::new(),
|
|
wrote: AtomicBool::new(false),
|
|
revision: AtomicUsize::new(0),
|
|
stored: StdMutex::new(None),
|
|
identity: StdMutex::new(None),
|
|
});
|
|
let snapshot = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None)],
|
|
..Default::default()
|
|
};
|
|
let mut write_state = super::PoolMetaWriteState::default();
|
|
let err = snapshot
|
|
.save_no_lock_armed(vec![storage.clone()], &mut write_state, guard.lock_lost_signal(), &[0])
|
|
.await
|
|
.expect_err("a writer must not persist after losing the distributed pool metadata fence");
|
|
|
|
assert!(err.to_string().contains("distributed fence was lost"));
|
|
assert!(!storage.wrote.load(Ordering::SeqCst), "the stale writer must not reach replica storage");
|
|
write_state
|
|
.ensure_write_safe("lost pool metadata fence")
|
|
.expect_err("a lost distributed fence must latch the sticky write gate");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_pool_meta_fence_loss_after_publish_keeps_write_gate_blocked() {
|
|
let client = Arc::new(LocalClient::with_manager(Arc::new(GlobalLockManager::new())));
|
|
let lock = NamespaceLock::with_clients_and_quorum("pool-meta-publish-fence-loss".to_string(), vec![client], 1);
|
|
let request = LockRequest::new(
|
|
ObjectKey::new(super::RUSTFS_META_BUCKET, super::POOL_META_NAME),
|
|
LockType::Exclusive,
|
|
"publishing-writer",
|
|
)
|
|
.with_acquire_timeout(StdDuration::from_secs(1))
|
|
.with_ttl(StdDuration::from_secs(1))
|
|
.with_refresh_interval(StdDuration::from_secs(1));
|
|
let guard = lock
|
|
.acquire_guard(&request)
|
|
.await
|
|
.expect("pool metadata fence acquisition should not error")
|
|
.expect("the publishing writer should acquire the pool metadata fence");
|
|
let storage = Arc::new(PartialPoolMetaWriteStorage {
|
|
fail_write: false,
|
|
fail_after_first_write: false,
|
|
pending_write: false,
|
|
write_started: tokio::sync::Notify::new(),
|
|
wrote: AtomicBool::new(false),
|
|
revision: AtomicUsize::new(0),
|
|
stored: StdMutex::new(None),
|
|
identity: StdMutex::new(None),
|
|
});
|
|
let mut saved = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None)],
|
|
..Default::default()
|
|
};
|
|
saved.pools[0].last_update = OffsetDateTime::UNIX_EPOCH + Duration::seconds(1);
|
|
let mut current = saved.clone();
|
|
current.pools[0].last_update = OffsetDateTime::UNIX_EPOCH;
|
|
let mut write_state = super::PoolMetaWriteState::for_test_bootstrap();
|
|
let outcome = saved
|
|
.save_no_lock_armed(vec![storage], &mut write_state, guard.lock_lost_signal(), &[0])
|
|
.await
|
|
.expect("the replica save should finish while the fence is valid");
|
|
|
|
ensure_pool_meta_write_fence(&guard, "test pool metadata publish")
|
|
.expect("the fence should remain valid before publication");
|
|
publish_pool_meta_updates(&mut current, &saved, &[0]);
|
|
tokio::time::timeout(StdDuration::from_secs(2), guard.lock_lost_notified())
|
|
.await
|
|
.expect("the fence should expire after publication");
|
|
ensure_pool_meta_write_fence(&guard, "test pool metadata publish")
|
|
.expect_err("the post-publication fence check must observe the loss");
|
|
|
|
assert_eq!(current.pools[0].last_update, saved.pools[0].last_update);
|
|
drop(outcome);
|
|
write_state
|
|
.ensure_write_safe("lost pool metadata publish fence")
|
|
.expect_err("the sticky write gate must remain armed after post-publication fence loss");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_clear_decommission_transaction_survives_caller_abort() {
|
|
let pool_meta = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![None]);
|
|
let save_started = Arc::new(tokio::sync::Notify::new());
|
|
let save_release = Arc::new(tokio::sync::Notify::new());
|
|
let save_completed = Arc::new(AtomicBool::new(false));
|
|
let caller_store = store.clone();
|
|
let caller_save_started = save_started.clone();
|
|
let caller_save_release = save_release.clone();
|
|
let caller_save_completed = save_completed.clone();
|
|
let caller_task = tokio::spawn(async move {
|
|
caller_store
|
|
.clear_decommission_with_save(0, move || async move {
|
|
caller_save_started.notify_one();
|
|
caller_save_release.notified().await;
|
|
caller_save_completed.store(true, Ordering::SeqCst);
|
|
Ok(())
|
|
})
|
|
.await
|
|
});
|
|
|
|
tokio::time::timeout(StdDuration::from_secs(1), save_started.notified())
|
|
.await
|
|
.expect("the clear transaction should reach persistence");
|
|
caller_task.abort();
|
|
assert!(
|
|
caller_task
|
|
.await
|
|
.expect_err("the RPC waiter should be cancelled")
|
|
.is_cancelled(),
|
|
"the clear caller should be aborted while persistence is pending"
|
|
);
|
|
save_release.notify_one();
|
|
|
|
let _start_guard = tokio::time::timeout(StdDuration::from_secs(1), store.start_gate.lock())
|
|
.await
|
|
.expect("the detached clear transaction should finish");
|
|
assert!(
|
|
save_completed.load(Ordering::SeqCst),
|
|
"the detached transaction must finish persistence after the caller is aborted"
|
|
);
|
|
let pool_meta = store.pool_meta.read().await;
|
|
assert!(
|
|
pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.is_some_and(|info| !info.has_decommission_state())
|
|
);
|
|
}
|
|
|
|
fn decommission_test_pool_endpoint(idx: usize, is_local: bool) -> PoolEndpoints {
|
|
let port = 9000usize + idx;
|
|
let mut endpoint =
|
|
Endpoint::try_from(format!("http://127.0.0.1:{port}/disk").as_str()).expect("test endpoint should parse");
|
|
endpoint.is_local = is_local;
|
|
endpoint.pool_idx = i32::try_from(idx).expect("test pool index should fit i32");
|
|
|
|
PoolEndpoints {
|
|
legacy: false,
|
|
set_count: 1,
|
|
drives_per_set: 1,
|
|
endpoints: Endpoints::from(vec![endpoint]),
|
|
cmd_line: format!("pool-{idx}"),
|
|
platform: String::new(),
|
|
}
|
|
}
|
|
|
|
fn decommission_test_pool_status(idx: usize, decommission: Option<PoolDecommissionInfo>) -> PoolStatus {
|
|
PoolStatus {
|
|
id: idx,
|
|
cmd_line: format!("pool-{idx}"),
|
|
last_update: OffsetDateTime::now_utc(),
|
|
decommission,
|
|
}
|
|
}
|
|
|
|
fn decommission_test_active_model_meta(model_versions: &[u16]) -> PoolMeta {
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: model_versions
|
|
.iter()
|
|
.copied()
|
|
.enumerate()
|
|
.map(|(idx, model_version)| {
|
|
let reservation = build_decommission_capacity_reservation_with_model(
|
|
DecommissionPoolCapacityInfo::for_test(idx, layout, 0, 10, 10),
|
|
layout,
|
|
uuid::Uuid::new_v4(),
|
|
u64::try_from(idx).unwrap_or_default() + 1,
|
|
OffsetDateTime::UNIX_EPOCH,
|
|
model_version,
|
|
)
|
|
.expect("test capacity model should be supported");
|
|
decommission_test_pool_status(
|
|
idx,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
capacity_reservation: Some(reservation),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
})
|
|
.collect(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn decommission_test_cleanup_meta(
|
|
model_version: u16,
|
|
temporary_mutations: Vec<DecommissionCapacityTemporaryMutation>,
|
|
inflight_physical_bytes: usize,
|
|
pending: Option<(uuid::Uuid, usize)>,
|
|
) -> PoolMeta {
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let mut reservation = build_decommission_capacity_reservation_with_model(
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 100, 100),
|
|
layout,
|
|
uuid::Uuid::new_v4(),
|
|
1,
|
|
OffsetDateTime::UNIX_EPOCH,
|
|
model_version,
|
|
)
|
|
.expect("test cleanup reservation should be valid");
|
|
let (pending_mutation_id, pending_physical_bytes) = pending.unzip();
|
|
let pending_physical_bytes = pending_physical_bytes.unwrap_or_default();
|
|
reservation.observed_target_physical_bytes = inflight_physical_bytes;
|
|
reservation.inflight_target_physical_bytes = inflight_physical_bytes;
|
|
reservation.pending_target_physical_bytes = pending_physical_bytes;
|
|
reservation.targets.push(DecommissionCapacityTarget {
|
|
pool_index: 1,
|
|
layout,
|
|
physical_total_at_reservation: 200,
|
|
physical_free_at_reservation: 200,
|
|
reserved_physical_bytes: reservation.peak_physical_bytes,
|
|
consumed_physical_bytes: 0,
|
|
observed_physical_bytes: inflight_physical_bytes,
|
|
inflight_physical_bytes,
|
|
pending_physical_bytes,
|
|
pending_mutation_id,
|
|
temporary_mutations,
|
|
});
|
|
PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![
|
|
decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
capacity_reservation: Some(reservation),
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(1, None),
|
|
],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_activation_fence_uses_one_lock_order_and_serializes_callers() {
|
|
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
|
|
let first = Arc::new(ActivationLockRecorder {
|
|
lock_manager: Arc::clone(&manager),
|
|
owner: "first",
|
|
resources: StdMutex::new(Vec::new()),
|
|
});
|
|
let second = Arc::new(ActivationLockRecorder {
|
|
lock_manager: manager,
|
|
owner: "second",
|
|
resources: StdMutex::new(Vec::new()),
|
|
});
|
|
|
|
let first_guards = acquire_pool_rebalance_activation_locks(first.clone(), None)
|
|
.await
|
|
.expect("first activation should acquire both locks");
|
|
assert_eq!(
|
|
*first
|
|
.resources
|
|
.lock()
|
|
.expect("activation lock recorder should not be poisoned"),
|
|
vec![POOL_META_NAME.to_string(), REBAL_META_NAME.to_string()]
|
|
);
|
|
|
|
let mut second_acquire = Box::pin(acquire_pool_rebalance_activation_locks(second.clone(), None));
|
|
let mut context = Context::from_waker(futures::task::noop_waker_ref());
|
|
assert!(matches!(second_acquire.as_mut().poll(&mut context), Poll::Pending));
|
|
|
|
drop(first_guards);
|
|
second_acquire
|
|
.await
|
|
.expect("second activation should acquire both locks after the first releases them");
|
|
assert_eq!(
|
|
*second
|
|
.resources
|
|
.lock()
|
|
.expect("activation lock recorder should not be poisoned"),
|
|
vec![POOL_META_NAME.to_string(), REBAL_META_NAME.to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_receipt_run_token_changes_with_persisted_start_time() {
|
|
let first = OffsetDateTime::from_unix_timestamp(1_000).expect("first run timestamp should be valid");
|
|
let second = OffsetDateTime::from_unix_timestamp(2_000).expect("second run timestamp should be valid");
|
|
let first_token = decommission_durable_ilm_receipt_run_token("pool-0", first);
|
|
let second_token = decommission_durable_ilm_receipt_run_token("pool-0", second);
|
|
|
|
assert_ne!(first_token, second_token);
|
|
assert_eq!(first_token, decommission_durable_ilm_receipt_run_token("pool-0", first));
|
|
let operation_id = "a".repeat(64);
|
|
let old_receipt = decommission_durable_ilm_receipt_path(
|
|
&first_token,
|
|
&format!("ilm/tier-delete-journal/{operation_id}.json"),
|
|
"operation_id",
|
|
&operation_id,
|
|
);
|
|
assert!(!old_receipt.starts_with(&decommission_durable_ilm_receipt_run_prefix(&second_token)));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_receipt_merge_preserves_terminal_proof() {
|
|
let operation_id = "a".repeat(64);
|
|
let source_path = format!("ilm/tier-delete-journal/{operation_id}.json");
|
|
let checkpoint = DurableIlmRecordCheckpoint::TierDeleteJournal {
|
|
content_sha256: "b".repeat(64),
|
|
identity_sha256: "c".repeat(64),
|
|
committed: false,
|
|
dispatch_identity_sha256: None,
|
|
state: None,
|
|
};
|
|
let terminal_checkpoint = DurableIlmRecordCheckpoint::TierDeleteJournal {
|
|
content_sha256: "d".repeat(64),
|
|
identity_sha256: "c".repeat(64),
|
|
committed: true,
|
|
dispatch_identity_sha256: None,
|
|
state: None,
|
|
};
|
|
let incoming = DecommissionDurableIlmReceipt {
|
|
source_path,
|
|
namespace: "tier-delete-journal".to_string(),
|
|
id_kind: "operation_id".to_string(),
|
|
id: operation_id,
|
|
checkpoint: checkpoint.clone(),
|
|
terminal_checkpoint: None,
|
|
fleet_topology_generation: None,
|
|
};
|
|
let existing = DecommissionDurableIlmReceipt {
|
|
terminal_checkpoint: Some(terminal_checkpoint.clone()),
|
|
..incoming.clone()
|
|
};
|
|
|
|
let merged = merge_decommission_durable_ilm_receipts(&existing, &incoming)
|
|
.expect("retry receipt must merge with a terminal receipt");
|
|
|
|
assert_eq!(merged.checkpoint, checkpoint);
|
|
assert_eq!(merged.terminal_checkpoint, Some(terminal_checkpoint.clone()));
|
|
|
|
let topology_bound = DecommissionDurableIlmReceipt {
|
|
fleet_topology_generation: Some("e".repeat(64)),
|
|
..incoming
|
|
};
|
|
let mixed_error = merge_decommission_durable_ilm_receipts(&existing, &topology_bound)
|
|
.expect_err("a topology-bound v6 receipt must not mask an unbound receipt")
|
|
.to_string();
|
|
assert!(mixed_error.contains("fleet topology conflict"));
|
|
|
|
let topology_existing = DecommissionDurableIlmReceipt {
|
|
terminal_checkpoint: Some(terminal_checkpoint),
|
|
..topology_bound.clone()
|
|
};
|
|
let topology_merged = merge_decommission_durable_ilm_receipts(&topology_existing, &topology_bound)
|
|
.expect("receipts bound to the same fleet topology should merge");
|
|
assert_eq!(topology_merged.fleet_topology_generation, Some("e".repeat(64)));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_manual_job_receipt_compacts_large_progress() {
|
|
let prefix = "p".repeat(12 * 1024);
|
|
let options = ManualTransitionRunOptions {
|
|
prefix,
|
|
..Default::default()
|
|
};
|
|
let mut job = ManualTransitionJobRecord::new(uuid::Uuid::new_v4(), "bounded-receipt-bucket", &options, "owner");
|
|
let token_bytes = serde_json::to_vec(&serde_json::json!({
|
|
"marker": "m".repeat(12 * 1024),
|
|
"version_marker": "opaque-version"
|
|
}))
|
|
.expect("large continuation token should encode");
|
|
let mut report = job.report.clone();
|
|
report.scanned = 1;
|
|
report.continuation_token = Some(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&token_bytes));
|
|
job.update_running_progress(report, ManualTransitionQueueSnapshot::default());
|
|
let path = manual_transition_job_record_object_name(job.job_id).expect("manual job path should build");
|
|
let job_bytes = job.encode().expect("large manual job should remain within its record limit");
|
|
assert!(job_bytes.len() > DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE);
|
|
let record = validate_durable_ilm_record(&path, &job_bytes).expect("large manual job should validate");
|
|
let expected_checkpoint = record.checkpoint.clone();
|
|
let mut receipt = DecommissionDurableIlmReceipt::new(&path, &record, None);
|
|
receipt.terminal_checkpoint = Some(record.checkpoint);
|
|
|
|
let encoded = receipt.encode().expect("bounded progress proof should fit the receipt limit");
|
|
let decoded = DecommissionDurableIlmReceipt::decode(&encoded).expect("bounded receipt should round trip");
|
|
|
|
assert!(encoded.len() <= DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE);
|
|
assert_eq!(decoded.source_path, path);
|
|
assert_eq!(decoded.checkpoint, expected_checkpoint);
|
|
assert_eq!(decoded.terminal_checkpoint, Some(expected_checkpoint));
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_decommission_status_space_info_adds_idle_pool_usage() {
|
|
let status = apply_decommission_status_space_info(
|
|
decommission_test_pool_status(0, None),
|
|
PoolSpaceInfo {
|
|
free: 25,
|
|
total: 100,
|
|
used: 75,
|
|
},
|
|
);
|
|
|
|
let decommission = status.decommission.expect("idle pool status should include usage info");
|
|
assert_eq!(decommission.total_size, 100);
|
|
assert_eq!(decommission.current_size, 25);
|
|
assert!(decommission.start_time.is_none());
|
|
assert!(!decommission.complete);
|
|
assert!(!decommission.failed);
|
|
assert!(!decommission.canceled);
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_decommission_status_space_info_refreshes_active_decommission_sizes() {
|
|
let status = apply_decommission_status_space_info(
|
|
decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
total_size: 1,
|
|
current_size: 1,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
PoolSpaceInfo {
|
|
free: 25,
|
|
total: 100,
|
|
used: 75,
|
|
},
|
|
);
|
|
|
|
let decommission = status.decommission.expect("active decommission info should remain present");
|
|
assert_eq!(decommission.total_size, 100);
|
|
assert_eq!(decommission.current_size, 25);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_pool_status_refresh_uses_persisted_terminal_decommission() {
|
|
let older = OffsetDateTime::from_unix_timestamp(1_000).expect("test timestamp should be valid");
|
|
let newer = OffsetDateTime::from_unix_timestamp(2_000).expect("test timestamp should be valid");
|
|
let mut current = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: older,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(older),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let persisted = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: newer,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(merge_pool_status_refresh(&mut current, persisted, &[false]));
|
|
|
|
let info = current.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("decommission info should be present");
|
|
assert!(info.complete);
|
|
assert!(!info.failed);
|
|
assert!(!info.canceled);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_pool_status_refresh_keeps_newer_local_active_progress() {
|
|
let older = OffsetDateTime::from_unix_timestamp(1_000).expect("test timestamp should be valid");
|
|
let newer = OffsetDateTime::from_unix_timestamp(2_000).expect("test timestamp should be valid");
|
|
let mut current = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: newer,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(older),
|
|
items_decommissioned: 10,
|
|
bytes_done: 1_024,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let persisted = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: older,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(older),
|
|
items_decommissioned: 1,
|
|
bytes_done: 128,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(!merge_pool_status_refresh(&mut current, persisted, &[true]));
|
|
|
|
let info = current.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("local decommission info should remain present");
|
|
assert_eq!(info.items_decommissioned, 10);
|
|
assert_eq!(info.bytes_done, 1_024);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_pool_status_refresh_preserves_observed_v2_floor() {
|
|
let timestamp = OffsetDateTime::from_unix_timestamp(1_000).expect("test timestamp should be valid");
|
|
let mut current = PoolMeta {
|
|
version: POOL_META_V1_VERSION,
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: timestamp,
|
|
decommission: Some(PoolDecommissionInfo::default()),
|
|
}],
|
|
dont_save: false,
|
|
};
|
|
let persisted = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: current.pools.clone(),
|
|
dont_save: false,
|
|
};
|
|
|
|
assert!(!merge_pool_status_refresh(&mut current, persisted, &[true]));
|
|
assert_eq!(current.version, POOL_META_VERSION);
|
|
let encoded = current
|
|
.encode_config_data_for_v2_gate(false)
|
|
.expect("a peer-observed v2 floor must remain sticky");
|
|
assert_eq!(LittleEndian::read_u16(&encoded[2..4]), POOL_META_VERSION);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_pool_status_refresh_keeps_newer_local_active_over_older_terminal() {
|
|
let older = OffsetDateTime::from_unix_timestamp(1_000).expect("test timestamp should be valid");
|
|
let newer = OffsetDateTime::from_unix_timestamp(2_000).expect("test timestamp should be valid");
|
|
let mut current = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: newer,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(older),
|
|
items_decommissioned: 10,
|
|
bytes_done: 1_024,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let persisted = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: older,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(!merge_pool_status_refresh(&mut current, persisted, &[true]));
|
|
|
|
let info = current.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("local active decommission info should remain present");
|
|
assert!(!info.failed);
|
|
assert_eq!(info.items_decommissioned, 10);
|
|
assert_eq!(info.bytes_done, 1_024);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_pool_status_refresh_fails_closed_on_missing_persisted_pools() {
|
|
let newer = OffsetDateTime::from_unix_timestamp(2_000).expect("test timestamp should be valid");
|
|
let mut current = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
current.pools[0].last_update = newer;
|
|
|
|
assert!(
|
|
!merge_pool_status_refresh(&mut current, PoolMeta::default(), &[false]),
|
|
"an empty persisted snapshot must fail closed instead of replacing local state"
|
|
);
|
|
|
|
let info = current.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("local decommission info should survive a missing snapshot");
|
|
assert!(info.complete);
|
|
assert_eq!(current.pools[0].last_update, newer);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_pool_status_refresh_ignores_mislabeled_pool_entries() {
|
|
let older = OffsetDateTime::from_unix_timestamp(1_000).expect("test timestamp should be valid");
|
|
let mut current = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(0, None)],
|
|
..Default::default()
|
|
};
|
|
let mut persisted = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(0, Some(PoolDecommissionInfo::default()))],
|
|
..Default::default()
|
|
};
|
|
persisted.pools[0].id = 7;
|
|
persisted.pools[0].last_update = older;
|
|
|
|
assert!(
|
|
!merge_pool_status_refresh(&mut current, persisted, &[false]),
|
|
"a pool entry whose id does not match its index must be ignored"
|
|
);
|
|
assert!(current.pools[0].decommission.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_save_merge_preserves_newer_untouched_pool() {
|
|
let older = OffsetDateTime::from_unix_timestamp(1_000).expect("test timestamp should be valid");
|
|
let newer = OffsetDateTime::from_unix_timestamp(2_000).expect("test timestamp should be valid");
|
|
let mut current = PoolMeta {
|
|
pools: vec![
|
|
PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: older,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: older,
|
|
decommission: None,
|
|
},
|
|
],
|
|
..Default::default()
|
|
};
|
|
let mut persisted = current.clone();
|
|
persisted.pools[1].last_update = newer;
|
|
persisted.pools[1].decommission = Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
});
|
|
|
|
assert!(current.clear_decommission(0).expect("terminal decommission should clear"));
|
|
merge_pool_meta_updates_for_save(&mut persisted, ¤t, &[0], "clear decommission")
|
|
.expect("the target pool update should merge into the latest snapshot");
|
|
|
|
assert!(
|
|
persisted.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.is_some_and(|info| !info.has_decommission_state())
|
|
);
|
|
assert_eq!(persisted.pools[1].last_update, newer);
|
|
assert!(persisted.pools[1].decommission.as_ref().is_some_and(|info| info.failed));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_save_merge_rejects_stale_terminal_resurrection() {
|
|
let older = OffsetDateTime::from_unix_timestamp(1_000).expect("test timestamp should be valid");
|
|
let newer = OffsetDateTime::from_unix_timestamp(2_000).expect("test timestamp should be valid");
|
|
let mut persisted = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: newer,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
canceled: true,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let stale = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: older,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(older),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = merge_pool_meta_updates_for_save(&mut persisted, &stale, &[0], "stale writer")
|
|
.expect_err("a stale active snapshot must not resurrect a canceled pool");
|
|
|
|
assert!(err.to_string().contains("stale pool metadata update rejected"));
|
|
assert!(persisted.pools[0].decommission.as_ref().is_some_and(|info| info.canceled));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_save_merge_rejects_clear_over_completed_or_unresolved_state() {
|
|
let timestamp = OffsetDateTime::from_unix_timestamp(2_000).expect("test timestamp should be valid");
|
|
for persisted_info in [
|
|
PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
},
|
|
PoolDecommissionInfo {
|
|
failed: true,
|
|
unresolved_entries: vec![DecommissionUnresolvedEntry {
|
|
bucket: "bucket-a".to_string(),
|
|
object: "object-a".to_string(),
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
source_generation: timestamp,
|
|
candidate_count: 1,
|
|
disk_error_count: 1,
|
|
observed_at: timestamp,
|
|
reason: "test unresolved entry".to_string(),
|
|
}],
|
|
..Default::default()
|
|
},
|
|
] {
|
|
let mut persisted = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: timestamp,
|
|
decommission: Some(persisted_info),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let cleared = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::now_utc(),
|
|
decommission: Some(PoolDecommissionInfo::default()),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = merge_pool_meta_updates_for_save(&mut persisted, &cleared, &[0], "clear decommission")
|
|
.expect_err("a stale clear must not erase completed or unresolved state");
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("completed or unresolved decommission state cannot be cleared")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_publish_preserves_untouched_runtime_progress() {
|
|
let mut current = PoolMeta {
|
|
pools: vec![
|
|
decommission_test_pool_status(0, None),
|
|
decommission_test_pool_status(
|
|
1,
|
|
Some(PoolDecommissionInfo {
|
|
items_decommissioned: 10,
|
|
bytes_done: 1_024,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
],
|
|
..Default::default()
|
|
};
|
|
let mut saved = current.clone();
|
|
saved.pools[0].decommission = Some(PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
});
|
|
let saved_pool_1 = saved.pools[1].decommission.as_mut().expect("pool 1 progress should exist");
|
|
saved_pool_1.items_decommissioned = 1;
|
|
saved_pool_1.bytes_done = 128;
|
|
|
|
publish_pool_meta_updates(&mut current, &saved, &[0]);
|
|
|
|
assert!(current.pools[0].decommission.as_ref().is_some_and(|info| info.complete));
|
|
let pool_1 = current.pools[1]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("pool 1 progress should remain present");
|
|
assert_eq!(pool_1.items_decommissioned, 10);
|
|
assert_eq!(pool_1.bytes_done, 1_024);
|
|
}
|
|
|
|
#[test]
|
|
fn test_dedup_indices_removes_duplicates_preserving_order() {
|
|
assert_eq!(dedup_indices(&[0, 2, 1, 2, 3, 0]), vec![0, 2, 1, 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_dedup_indices_handles_empty_input() {
|
|
let empty: Vec<usize> = Vec::new();
|
|
assert!(dedup_indices(&empty).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_decommission_bucket_concurrency_is_conservative() {
|
|
assert_eq!(default_decommission_bucket_concurrency(0), 1);
|
|
assert_eq!(default_decommission_bucket_concurrency(1), 1);
|
|
assert_eq!(default_decommission_bucket_concurrency(2), 2);
|
|
assert_eq!(default_decommission_bucket_concurrency(8), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_decommission_entry_concurrency_is_conservative() {
|
|
assert_eq!(default_decommission_entry_concurrency(0), 1);
|
|
assert_eq!(default_decommission_entry_concurrency(1), 1);
|
|
assert_eq!(default_decommission_entry_concurrency(4), 4);
|
|
assert_eq!(default_decommission_entry_concurrency(16), DECOMMISSION_ENTRY_CONCURRENCY_DEFAULT_CAP);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_entry_concurrency_clamps_operator_configuration() {
|
|
assert_eq!(clamp_decommission_entry_concurrency(0), 1);
|
|
assert_eq!(clamp_decommission_entry_concurrency(1), 1);
|
|
assert_eq!(
|
|
clamp_decommission_entry_concurrency(DECOMMISSION_ENTRY_CONCURRENCY_HARD_CAP),
|
|
DECOMMISSION_ENTRY_CONCURRENCY_HARD_CAP
|
|
);
|
|
assert_eq!(clamp_decommission_entry_concurrency(usize::MAX), DECOMMISSION_ENTRY_CONCURRENCY_HARD_CAP);
|
|
}
|
|
|
|
#[test]
|
|
fn test_split_decommission_buckets_keeps_meta_buckets_last() {
|
|
let (regular, meta) = split_decommission_buckets(vec![
|
|
DecomBucketInfo {
|
|
name: "bucket-a".to_string(),
|
|
..Default::default()
|
|
},
|
|
DecomBucketInfo {
|
|
name: crate::disk::RUSTFS_META_BUCKET.to_string(),
|
|
prefix: crate::config::com::CONFIG_PREFIX.to_string(),
|
|
},
|
|
DecomBucketInfo {
|
|
name: "bucket-b".to_string(),
|
|
..Default::default()
|
|
},
|
|
DecomBucketInfo {
|
|
name: crate::disk::RUSTFS_META_BUCKET.to_string(),
|
|
prefix: crate::disk::BUCKET_META_PREFIX.to_string(),
|
|
},
|
|
DecomBucketInfo {
|
|
name: crate::disk::RUSTFS_META_BUCKET.to_string(),
|
|
prefix: crate::bucket::lifecycle::ILM_META_PREFIX.to_string(),
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
regular.iter().map(|bucket| bucket.name.as_str()).collect::<Vec<_>>(),
|
|
vec!["bucket-a", "bucket-b",]
|
|
);
|
|
assert_eq!(
|
|
meta.iter().map(|bucket| bucket.prefix.as_str()).collect::<Vec<_>>(),
|
|
vec![
|
|
crate::config::com::CONFIG_PREFIX,
|
|
crate::disk::BUCKET_META_PREFIX,
|
|
crate::bucket::lifecycle::ILM_META_PREFIX,
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resume_reconciles_missing_decommission_meta_prefixes() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
queued_buckets: vec![
|
|
format!("{}/{}", crate::disk::RUSTFS_META_BUCKET, crate::config::com::CONFIG_PREFIX),
|
|
format!("{}/{}", crate::disk::RUSTFS_META_BUCKET, crate::disk::BUCKET_META_PREFIX),
|
|
],
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(reconcile_decommission_meta_buckets(&mut meta, 0));
|
|
assert_eq!(
|
|
meta.pending_buckets(0)
|
|
.iter()
|
|
.filter(|bucket| bucket.name == crate::disk::RUSTFS_META_BUCKET)
|
|
.map(|bucket| bucket.prefix.as_str())
|
|
.collect::<Vec<_>>(),
|
|
DECOMMISSION_META_PREFIXES
|
|
);
|
|
assert!(!reconcile_decommission_meta_buckets(&mut meta, 0));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_metadata_phase_precedes_regular_failure() {
|
|
let events = Arc::new(StdMutex::new(Vec::new()));
|
|
let err = run_decommission_phases(
|
|
CancellationToken::new(),
|
|
vec![
|
|
DecomBucketInfo {
|
|
name: "regular-fails".to_string(),
|
|
..Default::default()
|
|
},
|
|
DecomBucketInfo {
|
|
name: "regular-not-started".to_string(),
|
|
..Default::default()
|
|
},
|
|
],
|
|
vec![
|
|
DecomBucketInfo {
|
|
name: crate::disk::RUSTFS_META_BUCKET.to_string(),
|
|
prefix: crate::config::com::CONFIG_PREFIX.to_string(),
|
|
},
|
|
DecomBucketInfo {
|
|
name: crate::disk::RUSTFS_META_BUCKET.to_string(),
|
|
prefix: crate::disk::BUCKET_META_PREFIX.to_string(),
|
|
},
|
|
],
|
|
1,
|
|
{
|
|
let events = Arc::clone(&events);
|
|
move |bucket, _rx| {
|
|
let events = Arc::clone(&events);
|
|
Box::pin(async move {
|
|
let event = if bucket.name == crate::disk::RUSTFS_META_BUCKET {
|
|
format!("meta:{}", bucket.prefix)
|
|
} else {
|
|
format!("regular:{}", bucket.name)
|
|
};
|
|
events.lock().expect("phase event lock should not be poisoned").push(event);
|
|
if bucket.name == "regular-fails" {
|
|
Err(Error::SlowDown)
|
|
} else {
|
|
Ok(())
|
|
}
|
|
})
|
|
}
|
|
},
|
|
)
|
|
.await
|
|
.expect_err("regular failure should remain fatal after metadata completes");
|
|
|
|
assert!(matches!(err, Error::SlowDown));
|
|
assert_eq!(
|
|
*events.lock().expect("phase event lock should not be poisoned"),
|
|
vec![
|
|
format!("meta:{}", crate::config::com::CONFIG_PREFIX),
|
|
format!("meta:{}", crate::disk::BUCKET_META_PREFIX),
|
|
"regular:regular-fails".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_decommission_buckets_bounded_respects_limit() {
|
|
let rx = CancellationToken::new();
|
|
let running = Arc::new(AtomicUsize::new(0));
|
|
let max_running = Arc::new(AtomicUsize::new(0));
|
|
let started = Arc::new(AtomicUsize::new(0));
|
|
let buckets = (0..8)
|
|
.map(|idx| DecomBucketInfo {
|
|
name: format!("bucket-{idx}"),
|
|
..Default::default()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
run_decommission_buckets_bounded(rx, buckets, 2, {
|
|
let running = Arc::clone(&running);
|
|
let max_running = Arc::clone(&max_running);
|
|
let started = Arc::clone(&started);
|
|
move |_bucket, _rx| {
|
|
let running = Arc::clone(&running);
|
|
let max_running = Arc::clone(&max_running);
|
|
let started = Arc::clone(&started);
|
|
Box::pin(async move {
|
|
started.fetch_add(1, Ordering::SeqCst);
|
|
let current = running.fetch_add(1, Ordering::SeqCst) + 1;
|
|
max_running.fetch_max(current, Ordering::SeqCst);
|
|
tokio::time::sleep(StdDuration::from_millis(10)).await;
|
|
running.fetch_sub(1, Ordering::SeqCst);
|
|
Ok(())
|
|
})
|
|
}
|
|
})
|
|
.await
|
|
.expect("bounded bucket scheduler should complete");
|
|
|
|
assert_eq!(started.load(Ordering::SeqCst), 8);
|
|
assert_eq!(max_running.load(Ordering::SeqCst), 2);
|
|
assert_eq!(running.load(Ordering::SeqCst), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_decommission_buckets_bounded_cancels_and_stops_launching_after_failure() {
|
|
let rx = CancellationToken::new();
|
|
let started = Arc::new(AtomicUsize::new(0));
|
|
let observed_cancel = Arc::new(AtomicBool::new(false));
|
|
let buckets = (0..5)
|
|
.map(|idx| DecomBucketInfo {
|
|
name: format!("bucket-{idx}"),
|
|
..Default::default()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let err = tokio::time::timeout(
|
|
StdDuration::from_secs(2),
|
|
run_decommission_buckets_bounded(rx.clone(), buckets, 2, {
|
|
let started = Arc::clone(&started);
|
|
let observed_cancel = Arc::clone(&observed_cancel);
|
|
move |bucket, rx| {
|
|
let started = Arc::clone(&started);
|
|
let observed_cancel = Arc::clone(&observed_cancel);
|
|
Box::pin(async move {
|
|
started.fetch_add(1, Ordering::SeqCst);
|
|
if bucket.name == "bucket-0" {
|
|
while started.load(Ordering::SeqCst) < 2 {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
return Err(Error::SlowDown);
|
|
}
|
|
|
|
rx.cancelled().await;
|
|
observed_cancel.store(true, Ordering::SeqCst);
|
|
Ok(())
|
|
})
|
|
}
|
|
}),
|
|
)
|
|
.await
|
|
.expect("bucket scheduler should not hang after a bucket failure")
|
|
.expect_err("first bucket failure should be returned");
|
|
|
|
assert!(matches!(err, Error::SlowDown));
|
|
assert!(rx.is_cancelled());
|
|
assert!(observed_cancel.load(Ordering::SeqCst));
|
|
assert_eq!(started.load(Ordering::SeqCst), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_decommission_buckets_bounded_external_cancel_stops_pending_buckets() {
|
|
let rx = CancellationToken::new();
|
|
let started = Arc::new(AtomicUsize::new(0));
|
|
let buckets = (0..4)
|
|
.map(|idx| DecomBucketInfo {
|
|
name: format!("bucket-{idx}"),
|
|
..Default::default()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let err = run_decommission_buckets_bounded(rx.clone(), buckets, 1, {
|
|
let started = Arc::clone(&started);
|
|
move |_bucket, rx| {
|
|
let started = Arc::clone(&started);
|
|
Box::pin(async move {
|
|
started.fetch_add(1, Ordering::SeqCst);
|
|
rx.cancel();
|
|
Ok(())
|
|
})
|
|
}
|
|
})
|
|
.await
|
|
.expect_err("external cancellation with pending buckets should stop the scheduler");
|
|
|
|
assert!(matches!(err, Error::OperationCanceled));
|
|
assert!(rx.is_cancelled());
|
|
assert_eq!(started.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_wait_decommission_worker_drain_waits_for_entry_permit() {
|
|
let workers = Arc::new(Semaphore::new(1));
|
|
let permit = workers
|
|
.clone()
|
|
.acquire_owned()
|
|
.await
|
|
.expect("test worker permit should acquire");
|
|
|
|
let drain = tokio::spawn({
|
|
let workers = workers.clone();
|
|
async move { wait_decommission_worker_drain(&workers, 1).await }
|
|
});
|
|
|
|
tokio::task::yield_now().await;
|
|
assert!(!drain.is_finished(), "drain should wait while a worker permit is held");
|
|
|
|
drop(permit);
|
|
let result = tokio::time::timeout(StdDuration::from_secs(1), drain)
|
|
.await
|
|
.expect("drain should finish after permit release")
|
|
.expect("drain task should not panic");
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_entry_queue_capacity_is_bounded() {
|
|
assert_eq!(decommission_entry_queue_capacity(0), 1);
|
|
assert_eq!(decommission_entry_queue_capacity(1), 2);
|
|
assert_eq!(
|
|
decommission_entry_queue_capacity(DECOMMISSION_ENTRY_QUEUE_HARD_CAP),
|
|
DECOMMISSION_ENTRY_QUEUE_HARD_CAP
|
|
);
|
|
assert_eq!(decommission_entry_queue_capacity(usize::MAX), DECOMMISSION_ENTRY_QUEUE_HARD_CAP);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_drain_decommission_entry_queue_waits_for_all_outstanding_entries() {
|
|
let outstanding = Arc::new(Semaphore::new(1));
|
|
let held = outstanding
|
|
.clone()
|
|
.acquire_owned()
|
|
.await
|
|
.expect("test outstanding permit should acquire");
|
|
let rx = CancellationToken::new();
|
|
let drain = tokio::spawn({
|
|
let outstanding = outstanding.clone();
|
|
let rx = rx.clone();
|
|
async move { drain_decommission_entry_queue(&rx, &outstanding, 1).await }
|
|
});
|
|
|
|
tokio::task::yield_now().await;
|
|
assert!(!drain.is_finished(), "queue drain must wait for active entry work");
|
|
drop(held);
|
|
|
|
let drained = tokio::time::timeout(StdDuration::from_secs(1), drain)
|
|
.await
|
|
.expect("queue drain should finish after entry completion")
|
|
.expect("queue drain task should not panic");
|
|
assert!(!drained);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_enqueue_decommission_entry_observes_cancellation_when_queue_is_full() {
|
|
let outstanding = Arc::new(Semaphore::new(2));
|
|
let (tx, mut queue) = tokio::sync::mpsc::channel(1);
|
|
let held = outstanding
|
|
.clone()
|
|
.acquire_owned()
|
|
.await
|
|
.expect("first queue permit should acquire");
|
|
tx.send(QueuedDecommissionEntry {
|
|
entry: MetaCacheEntry::default(),
|
|
queue_permit: held,
|
|
})
|
|
.await
|
|
.expect("first entry should fill the queue");
|
|
|
|
let rx = CancellationToken::new();
|
|
let enqueue = tokio::spawn({
|
|
let rx = rx.clone();
|
|
let outstanding = outstanding.clone();
|
|
let tx = tx.clone();
|
|
async move { enqueue_decommission_entry(&rx, &outstanding, &tx, MetaCacheEntry::default()).await }
|
|
});
|
|
|
|
tokio::task::yield_now().await;
|
|
rx.cancel();
|
|
let result = tokio::time::timeout(StdDuration::from_secs(1), enqueue)
|
|
.await
|
|
.expect("full queue enqueue should observe cancellation")
|
|
.expect("enqueue task should not panic");
|
|
assert!(matches!(result, DecommissionEntryEnqueueResult::Canceled));
|
|
drop(queue.recv().await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_side_effect_gate_quiesces_before_transition() {
|
|
let operation_gate = Arc::new(tokio::sync::RwLock::new(()));
|
|
let rx = CancellationToken::new();
|
|
let started = Arc::new(tokio::sync::Notify::new());
|
|
let release = Arc::new(tokio::sync::Notify::new());
|
|
let operation = tokio::spawn({
|
|
let operation_gate = operation_gate.clone();
|
|
let rx = rx.clone();
|
|
let started = started.clone();
|
|
let release = release.clone();
|
|
async move {
|
|
run_decommission_side_effect(&rx, &operation_gate, || async {
|
|
started.notify_one();
|
|
release.notified().await;
|
|
Ok::<_, Error>(())
|
|
})
|
|
.await
|
|
}
|
|
});
|
|
|
|
started.notified().await;
|
|
rx.cancel();
|
|
let transition = tokio::spawn({
|
|
let operation_gate = operation_gate.clone();
|
|
async move {
|
|
let _guard = operation_gate.write().await;
|
|
}
|
|
});
|
|
tokio::task::yield_now().await;
|
|
assert!(!transition.is_finished(), "transition must wait for the in-flight side effect");
|
|
|
|
release.notify_one();
|
|
let operation_result = operation.await.expect("operation task should not panic");
|
|
assert!(matches!(operation_result, Err(Error::OperationCanceled)));
|
|
transition.await.expect("transition task should not panic");
|
|
|
|
let called = Arc::new(AtomicBool::new(false));
|
|
let result = run_decommission_side_effect(&rx, &operation_gate, {
|
|
let called = called.clone();
|
|
move || async move {
|
|
called.store(true, Ordering::SeqCst);
|
|
Ok::<_, Error>(())
|
|
}
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(Error::OperationCanceled)));
|
|
assert!(!called.load(Ordering::SeqCst));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_side_effect_stops_after_pool_meta_write_block() {
|
|
let store = decommission_worker_test_store(PoolMeta::default(), Vec::new());
|
|
store
|
|
.pool_meta_save_gate
|
|
.lock()
|
|
.await
|
|
.observe_replicas(super::PoolMetaReplicaState {
|
|
needs_repair: true,
|
|
repair_write_safe: false,
|
|
});
|
|
let called = Arc::new(AtomicBool::new(false));
|
|
let operation_gate = store.ctx.data_movement_operation_gate();
|
|
|
|
let result = store
|
|
.run_guarded_decommission_side_effect(&CancellationToken::new(), &operation_gate, {
|
|
let called = called.clone();
|
|
move || async move {
|
|
called.store(true, Ordering::SeqCst);
|
|
Ok::<_, Error>(())
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
result
|
|
.expect_err("sticky pool metadata state must block new movement")
|
|
.to_string()
|
|
.contains("restart after all replicas are readable and consistent")
|
|
);
|
|
assert!(!called.load(Ordering::SeqCst));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_reservation_stops_before_canceler_slot_when_pool_meta_is_blocked() {
|
|
let generation = OffsetDateTime::UNIX_EPOCH;
|
|
let pool_meta = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![None]);
|
|
store
|
|
.pool_meta_save_gate
|
|
.lock()
|
|
.await
|
|
.observe_replicas(super::PoolMetaReplicaState {
|
|
needs_repair: true,
|
|
repair_write_safe: false,
|
|
});
|
|
|
|
let err = store
|
|
.reserve_decommission_routines(&CancellationToken::new(), &[0])
|
|
.await
|
|
.err()
|
|
.expect("sticky pool metadata state must block worker reservation");
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("restart after all replicas are readable and consistent")
|
|
);
|
|
assert!(store.decommission_cancelers.read().await[0].is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_v1_unresolved_ledger_rejection_keeps_live_state_and_write_gate_safe() {
|
|
let generation = OffsetDateTime::UNIX_EPOCH;
|
|
let status = decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
}),
|
|
);
|
|
let last_update = status.last_update;
|
|
let store = decommission_worker_test_store(
|
|
PoolMeta {
|
|
version: POOL_META_V1_VERSION,
|
|
pools: vec![status],
|
|
..Default::default()
|
|
},
|
|
vec![None],
|
|
);
|
|
let entry = DecommissionUnresolvedEntry {
|
|
bucket: "bucket-a".to_string(),
|
|
object: "directory/".to_string(),
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
source_generation: generation,
|
|
candidate_count: 1,
|
|
disk_error_count: 0,
|
|
observed_at: generation,
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
};
|
|
|
|
let err = store
|
|
.persist_decommission_unresolved_entry(0, generation, entry)
|
|
.await
|
|
.expect_err("V1 must reject the ledger before changing live state");
|
|
|
|
assert!(matches!(err, Error::InvalidArgument(..)));
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let status = &pool_meta.pools[0];
|
|
assert_eq!(status.last_update, last_update);
|
|
assert!(
|
|
status
|
|
.decommission
|
|
.as_ref()
|
|
.expect("active decommission metadata should remain present")
|
|
.unresolved_entries
|
|
.is_empty()
|
|
);
|
|
drop(pool_meta);
|
|
store
|
|
.pool_meta_save_gate
|
|
.lock()
|
|
.await
|
|
.ensure_write_safe("V1 unresolved-entry preflight")
|
|
.expect("a deterministic capability rejection must not latch recovery");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_v1_runtime_recovery_rejects_worker_but_keeps_cancel_persistable() {
|
|
let generation = OffsetDateTime::UNIX_EPOCH;
|
|
let store = decommission_worker_test_store(
|
|
PoolMeta {
|
|
version: POOL_META_V1_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
},
|
|
vec![None],
|
|
);
|
|
|
|
let err = store
|
|
.reserve_decommission_routines(&CancellationToken::new(), &[0])
|
|
.await
|
|
.err()
|
|
.expect("V1 recovery must not install a worker that cannot persist an unresolved ledger");
|
|
assert!(matches!(err, Error::InvalidArgument(..)));
|
|
assert!(store.decommission_cancelers.read().await[0].is_none());
|
|
|
|
let save_called = Arc::new(AtomicBool::new(false));
|
|
store
|
|
.decommission_cancel_with_owner_and_save(0, None, {
|
|
let save_called = save_called.clone();
|
|
move |snapshot, _| async move {
|
|
snapshot.encode_config_data_for_v2_gate(false)?;
|
|
save_called.store(true, Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
})
|
|
.await
|
|
.expect("a rejected V1 recovery must remain cancelable without restart");
|
|
|
|
assert!(save_called.load(Ordering::SeqCst));
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("cancel metadata should remain present");
|
|
assert!(info.canceled);
|
|
assert!(!info.failed);
|
|
assert!(!info.complete);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_transition_waits_without_registered_canceler() {
|
|
let store = decommission_worker_test_store(PoolMeta::default(), vec![None]);
|
|
let operation_gate = store.ctx.data_movement_operation_gate();
|
|
let operation_guard = operation_gate.read().await;
|
|
let transition = tokio::spawn({
|
|
let store = store.clone();
|
|
async move { store.cancel_decommission_routines_and_wait(&[0]).await }
|
|
});
|
|
|
|
tokio::task::yield_now().await;
|
|
assert!(
|
|
!transition.is_finished(),
|
|
"a transition must wait for an in-flight side effect even after its canceler slot is gone"
|
|
);
|
|
|
|
drop(operation_guard);
|
|
tokio::time::timeout(StdDuration::from_secs(1), transition)
|
|
.await
|
|
.expect("transition should finish after the side effect")
|
|
.expect("transition task should not panic");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_join_error_quiesces_side_effects_before_failure_transition() {
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let store = decommission_worker_test_store(PoolMeta::default(), vec![Some(canceler.clone())]);
|
|
let operation_gate = store.ctx.data_movement_operation_gate();
|
|
let operation_guard = operation_gate.read().await;
|
|
let transition = tokio::spawn({
|
|
let store = store.clone();
|
|
let canceler = canceler.clone();
|
|
async move { store.quiesce_decommission_worker_after_join_error(&canceler).await }
|
|
});
|
|
|
|
tokio::time::timeout(StdDuration::from_secs(1), canceler.token().cancelled())
|
|
.await
|
|
.expect("join error handling should cancel the detached worker");
|
|
assert!(
|
|
!transition.is_finished(),
|
|
"failure transition must wait for the detached worker's in-flight side effect"
|
|
);
|
|
|
|
drop(operation_guard);
|
|
tokio::time::timeout(StdDuration::from_secs(1), transition)
|
|
.await
|
|
.expect("failure transition should finish after the side effect")
|
|
.expect("failure transition task should not panic");
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn test_run_decommission_listing_with_retry_drains_before_each_retry() {
|
|
let attempts = Arc::new(AtomicUsize::new(0));
|
|
let drains = Arc::new(AtomicUsize::new(0));
|
|
let err = run_decommission_listing_with_retry_and_drain(
|
|
CancellationToken::new(),
|
|
"bucket-a".to_string(),
|
|
noop_decommission_list_callback(),
|
|
1,
|
|
2,
|
|
2,
|
|
{
|
|
let attempts = attempts.clone();
|
|
move |_| {
|
|
let attempts = attempts.clone();
|
|
async move {
|
|
attempts.fetch_add(1, Ordering::SeqCst);
|
|
Err(Error::SlowDown)
|
|
}
|
|
}
|
|
},
|
|
{
|
|
let drains = drains.clone();
|
|
move || {
|
|
let drains = drains.clone();
|
|
async move {
|
|
drains.fetch_add(1, Ordering::SeqCst);
|
|
false
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.await
|
|
.expect_err("permanent listing failure must be returned");
|
|
|
|
assert!(err.to_string().contains("attempt 2/2"));
|
|
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
|
assert_eq!(drains.load(Ordering::SeqCst), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_by_index_returns_value_when_in_range() {
|
|
let values = vec!["a", "b", "c"];
|
|
let value = get_by_index(values.as_slice(), 1, "fetch decommission status").expect("in-range index should return value");
|
|
assert_eq!(*value, "b");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_by_index_returns_error_when_out_of_range() {
|
|
let values = vec![1_u8];
|
|
let err =
|
|
get_by_index(values.as_slice(), 2, "load decommission background pool").expect_err("out-of-range index should fail");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to load decommission background pool: invalid decommission pool index 2 for 1 pools")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_is_suspended_returns_false_for_out_of_range() {
|
|
let meta = PoolMeta::default();
|
|
assert!(!meta.is_suspended(1));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rollback_start_decommission_pool_meta_clears_active_state() {
|
|
let previous = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut active = previous.clone();
|
|
let active_update = OffsetDateTime::UNIX_EPOCH + Duration::seconds(1);
|
|
active.pools[0].last_update = active_update;
|
|
active.pools[0].decommission = Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
});
|
|
let mut peer = active.clone();
|
|
|
|
assert!(active.is_suspended(0));
|
|
assert_eq!(
|
|
decommission_start_pool_state(active.pools.first()),
|
|
DecommissionStartPoolState::Decommissioning
|
|
);
|
|
|
|
rollback_start_decommission_pool_meta(&mut active, &previous, &[0]);
|
|
|
|
assert!(!active.is_suspended(0));
|
|
assert_eq!(decommission_start_pool_state(active.pools.first()), DecommissionStartPoolState::Active);
|
|
assert!(active.pools[0].last_update > active_update);
|
|
assert!(merge_pool_status_refresh(&mut peer, active, &[false]));
|
|
assert!(peer.pools[0].decommission.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_queue_buckets_ignores_out_of_range_index() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo::default()),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
meta.queue_buckets(
|
|
9,
|
|
vec![DecomBucketInfo {
|
|
name: "bucket-a".to_string(),
|
|
prefix: String::new(),
|
|
}],
|
|
);
|
|
|
|
let queued = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("pool should have decommission info")
|
|
.queued_buckets
|
|
.clone();
|
|
assert!(queued.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_is_bucket_decommissioned_returns_false_for_out_of_range() {
|
|
let meta = PoolMeta::default();
|
|
assert!(!meta.is_bucket_decommissioned(7, "bucket-a".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_bucket_state_rejects_out_of_range_index() {
|
|
let meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo::default()),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let bucket = DecomBucketInfo {
|
|
name: "bucket-a".to_string(),
|
|
prefix: String::new(),
|
|
};
|
|
let err =
|
|
resolve_decommission_bucket_state(&meta, 3, &bucket).expect_err("out-of-range index should return invalid argument");
|
|
assert!(err.to_string().contains("invalid decommission pool index 3 for 1 pools"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_bucket_state_rejects_missing_decommission_meta() {
|
|
let meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let bucket = DecomBucketInfo {
|
|
name: "bucket-a".to_string(),
|
|
prefix: String::new(),
|
|
};
|
|
let err = resolve_decommission_bucket_state(&meta, 0, &bucket)
|
|
.expect_err("missing decommission metadata should return explicit error");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to resolve decommission bucket state: decommission metadata not initialized")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_bucket_state_returns_true_for_done_bucket() {
|
|
let meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
decommissioned_buckets: vec!["bucket-a".to_string()],
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let bucket = DecomBucketInfo {
|
|
name: "bucket-a".to_string(),
|
|
prefix: String::new(),
|
|
};
|
|
let done = resolve_decommission_bucket_state(&meta, 0, &bucket).expect("valid state should resolve");
|
|
assert!(done);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mark_decommission_bucket_done_rejects_missing_decommission_meta() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let bucket = DecomBucketInfo {
|
|
name: "bucket-a".to_string(),
|
|
prefix: String::new(),
|
|
};
|
|
let err = mark_decommission_bucket_done(&mut meta, 0, &bucket)
|
|
.expect_err("missing decommission metadata should return explicit error");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to mark decommission bucket done: decommission metadata not initialized")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mark_decommission_bucket_done_rejects_out_of_range_index() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo::default()),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let bucket = DecomBucketInfo {
|
|
name: "bucket-a".to_string(),
|
|
prefix: String::new(),
|
|
};
|
|
let err =
|
|
mark_decommission_bucket_done(&mut meta, 1, &bucket).expect_err("out-of-range index should return invalid argument");
|
|
assert!(err.to_string().contains("invalid decommission pool index 1 for 1 pools"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_mark_decommission_bucket_done_pops_bucket_when_present() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
queued_buckets: vec!["bucket-a".to_string()],
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let bucket = DecomBucketInfo {
|
|
name: "bucket-a".to_string(),
|
|
prefix: String::new(),
|
|
};
|
|
let popped = mark_decommission_bucket_done(&mut meta, 0, &bucket).expect("valid state should mark bucket done");
|
|
assert!(popped);
|
|
}
|
|
|
|
#[test]
|
|
fn test_count_decommission_item_rejects_missing_decommission_meta() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = count_decommission_item(&mut meta, 0, 64, true)
|
|
.expect_err("missing decommission metadata should return explicit error");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to count decommission item: decommission metadata not initialized")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_count_decommission_item_updates_done_and_failed_counters() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo::default()),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
count_decommission_item(&mut meta, 0, 32, false).expect("success counter should be updated");
|
|
count_decommission_item(&mut meta, 0, 16, true).expect("failed counter should be updated");
|
|
|
|
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
|
|
assert_eq!(info.items_decommissioned, 1);
|
|
assert_eq!(info.bytes_done, 32);
|
|
assert_eq!(info.items_decommission_failed, 1);
|
|
assert_eq!(info.bytes_failed, 16);
|
|
}
|
|
|
|
#[test]
|
|
fn test_track_decommission_current_object_rejects_missing_decommission_meta() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = track_decommission_current_object(&mut meta, 0, "bucket-a", "object-a")
|
|
.expect_err("missing decommission metadata should return explicit error");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to track decommission current object: decommission metadata not initialized")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_track_decommission_current_object_updates_bucket_and_object() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo::default()),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
track_decommission_current_object(&mut meta, 0, "bucket-a", "object-a").expect("valid state should track bucket/object");
|
|
|
|
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
|
|
assert_eq!(info.bucket, "bucket-a");
|
|
assert_eq!(info.object, "object-a");
|
|
assert!(info.stage.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_track_decommission_current_object_stage_updates_stage() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo::default()),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
track_decommission_current_object_stage(&mut meta, 0, "bucket-a", "object-a", "cleanup_preflight")
|
|
.expect("valid state should track bucket/object stage");
|
|
|
|
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
|
|
assert_eq!(info.bucket, "bucket-a");
|
|
assert_eq!(info.object, "object-a");
|
|
assert_eq!(info.stage, "cleanup_preflight");
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_update_after_result_passthrough_ok() {
|
|
let ok = resolve_decommission_update_after_result(Ok(true)).expect("ok value should pass through");
|
|
assert!(ok);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_update_after_result_wraps_error_context() {
|
|
let err = resolve_decommission_update_after_result(ensure_valid_decommission_pool_index(0, 0).map(|_| false))
|
|
.expect_err("invalid argument should be wrapped with context");
|
|
assert!(err.to_string().contains("decommission metadata update failed"));
|
|
assert!(err.to_string().contains("invalid decommission pool index 0 for 0 pools"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_progress_save_result_returns_none_on_success() {
|
|
assert!(resolve_decommission_progress_save_result(Ok(())).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_progress_save_result_returns_error_for_best_effort_failure() {
|
|
let err = resolve_decommission_progress_save_result(Err(Error::SlowDown))
|
|
.expect("progress save failure should be returned for logging");
|
|
|
|
assert!(err.to_string().contains("decommission progress save failed"));
|
|
assert!(err.to_string().contains(Error::SlowDown.to_string().as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_preflight_heal_result_passthrough_ok() {
|
|
assert!(resolve_decommission_preflight_heal_result::<()>("bucket-a", Ok(())).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_preflight_heal_result_wraps_error_context() {
|
|
let err = resolve_decommission_preflight_heal_result::<()>("bucket-a", Err(Error::SlowDown))
|
|
.expect_err("heal failure should carry preflight context");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("decommission preflight heal failed for bucket bucket-a")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_optional_bucket_config_result_passthrough() {
|
|
let result = resolve_decommission_optional_bucket_config_result("bucket-a", "replication", Ok(42_u8))
|
|
.expect("bucket config should pass through");
|
|
assert_eq!(result, Some(42));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_optional_bucket_config_result_returns_none_for_missing_config() {
|
|
let result =
|
|
resolve_decommission_optional_bucket_config_result::<()>("bucket-a", "versioning", Err(Error::ConfigNotFound))
|
|
.expect("missing bucket config should map to None");
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_optional_bucket_config_result_wraps_other_errors() {
|
|
let err = resolve_decommission_optional_bucket_config_result::<()>("bucket-a", "replication", Err(Error::SlowDown))
|
|
.expect_err("unexpected bucket config errors should be wrapped with context");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("decommission replication config load failed for bucket bucket-a")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_entry_cleanup_delete_result_passthrough_ok() {
|
|
assert!(resolve_decommission_entry_cleanup_delete_result(Ok(()), "bucket-a", "obj.txt").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_entry_cleanup_delete_result_ignores_not_found() {
|
|
assert!(resolve_decommission_entry_cleanup_delete_result::<()>(Err(Error::FileNotFound), "bucket-a", "obj.txt").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_entry_cleanup_delete_result_wraps_error_context() {
|
|
let err = resolve_decommission_entry_cleanup_delete_result::<()>(Err(Error::SlowDown), "bucket-a", "obj.txt")
|
|
.expect_err("cleanup delete failure should be wrapped with explicit context");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("decommission cleanup_delete_object failed for bucket-a/obj.txt")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_entry_reload_result_passthrough_ok() {
|
|
assert!(resolve_decommission_entry_reload_result(Ok(()), "bucket-a", "obj.txt").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_entry_reload_result_wraps_error_context() {
|
|
let err = resolve_decommission_entry_reload_result(Err(Error::SlowDown), "bucket-a", "obj.txt")
|
|
.expect_err("reload failure should be wrapped with explicit context");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("decommission reload_pool_meta failed for bucket-a/obj.txt")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_terminal_mark_result_passthrough_ok() {
|
|
assert!(resolve_decommission_terminal_mark_result(Ok(()), "completed", "pool-a").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_terminal_mark_result_wraps_error_context() {
|
|
let err = resolve_decommission_terminal_mark_result(Err(Error::SlowDown), "failed", "pool-a")
|
|
.expect_err("terminal mark failure should include stage and pool context");
|
|
let message = err.to_string();
|
|
assert!(message.contains("decommission terminal mark failed failed for pool pool-a"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_terminal_mark_after_error_result_passthrough_ok() {
|
|
assert!(resolve_decommission_terminal_mark_after_error_result(Ok(()), 3, &Error::SlowDown).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_terminal_mark_after_error_result_wraps_error_context() {
|
|
let err = resolve_decommission_terminal_mark_after_error_result(Err(Error::OperationCanceled), 3, &Error::SlowDown)
|
|
.expect_err("terminal mark after-error failure should include both errors");
|
|
let message = err.to_string();
|
|
assert!(message.contains("decommission terminal mark failed after background error on pool 3"));
|
|
assert!(message.contains("mark error"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_observe_decommission_terminal_reload_result_returns_none_on_success() {
|
|
assert!(observe_decommission_terminal_reload_result(Ok(()), "complete_decommission for pool 3").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_observe_decommission_terminal_reload_result_keeps_failure_for_logging() {
|
|
let err = observe_decommission_terminal_reload_result(Err(Error::SlowDown), "decommission_failed for pool 3")
|
|
.expect("reload failure should be observable");
|
|
let message = err.to_string();
|
|
assert!(message.contains("decommission terminal pool meta reload failed during decommission_failed for pool 3"));
|
|
assert!(message.contains(Error::SlowDown.to_string().as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_item_size_converts_positive_values() {
|
|
assert_eq!(decommission_item_size(42_i64), 42);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_item_size_clamps_negative_values_to_zero() {
|
|
assert_eq!(decommission_item_size(-1_i64), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_multipart_abort_flag_defaults_to_abort_enabled() {
|
|
let flag = data_movement::new_multipart_abort_flag();
|
|
assert!(data_movement::should_abort_multipart_upload(&flag));
|
|
}
|
|
|
|
#[test]
|
|
fn test_mark_multipart_upload_completed_disables_abort_cleanup() {
|
|
let flag = data_movement::new_multipart_abort_flag();
|
|
data_movement::mark_multipart_upload_completed(&flag);
|
|
assert!(!data_movement::should_abort_multipart_upload(&flag));
|
|
}
|
|
|
|
#[test]
|
|
fn test_decode_part_index_returns_some_for_valid_payload() {
|
|
let mut index = Index::new();
|
|
index.add(0, 0).expect("first index entry should be accepted");
|
|
index
|
|
.add(2_097_152, 2_097_152)
|
|
.expect("second index entry should advance totals");
|
|
|
|
let encoded = index.into_vec();
|
|
let decoded = data_movement::decode_part_index(Some(&encoded)).expect("valid index payload should decode");
|
|
|
|
assert_eq!(decoded.total_uncompressed, 2_097_152);
|
|
assert_eq!(decoded.total_compressed, 2_097_152);
|
|
}
|
|
|
|
#[test]
|
|
fn test_with_decommission_entry_context_formats_stage_bucket_and_object() {
|
|
let err = with_decommission_entry_context("update_after", "bucket-a", "obj.txt", Error::SlowDown);
|
|
let message = err.to_string();
|
|
assert!(message.contains("decommission entry update_after failed"));
|
|
assert!(message.contains("bucket bucket-a"));
|
|
assert!(message.contains("object obj.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_decommission_entry_versions_wraps_parse_errors_with_context() {
|
|
let entry = MetaCacheEntry {
|
|
name: "obj.txt".to_string(),
|
|
metadata: vec![1, 2, 3],
|
|
cached: None,
|
|
reusable: false,
|
|
};
|
|
|
|
let err = load_decommission_entry_versions(&entry, "bucket-a", "check_after_decommission.file_info_versions")
|
|
.expect_err("invalid metadata should fail");
|
|
let message = err.to_string();
|
|
assert!(message.contains("decommission entry check_after_decommission.file_info_versions failed"));
|
|
assert!(message.contains("bucket bucket-a"));
|
|
assert!(message.contains("object obj.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_entry_exact_versions_preserves_full_parts() {
|
|
let entry = MetaCacheEntry {
|
|
name: "obj.txt".to_string(),
|
|
metadata: Vec::new(),
|
|
cached: None,
|
|
reusable: false,
|
|
};
|
|
let fivs = FileInfoVersions {
|
|
volume: "bucket-a".to_string(),
|
|
name: "obj.txt".to_string(),
|
|
versions: vec![FileInfo {
|
|
name: "obj.txt".to_string(),
|
|
parts: vec![ObjectPartInfo {
|
|
number: 1,
|
|
etag: "part-etag".to_string(),
|
|
size: 128,
|
|
actual_size: 128,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let resolved = resolve_decommission_entry_exact_versions(Ok(Some(fivs)), &entry, "bucket-a", "file_info_versions")
|
|
.expect("exact versions should be preserved");
|
|
|
|
assert_eq!(resolved.versions[0].parts.len(), 1);
|
|
assert_eq!(resolved.versions[0].parts[0].etag, "part-etag");
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_entry_exact_versions_uses_empty_when_source_missing() {
|
|
let entry = MetaCacheEntry {
|
|
name: "obj.txt".to_string(),
|
|
metadata: Vec::new(),
|
|
cached: None,
|
|
reusable: false,
|
|
};
|
|
|
|
let resolved = resolve_decommission_entry_exact_versions(Ok(None), &entry, "bucket-a", "file_info_versions")
|
|
.expect("missing source metadata should be treated as empty");
|
|
|
|
assert_eq!(resolved.volume, "bucket-a");
|
|
assert_eq!(resolved.name, "obj.txt");
|
|
assert!(resolved.versions.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_check_after_list_result_prefers_entry_error() {
|
|
let err = resolve_decommission_check_after_list_result(Err(Error::OperationCanceled), Some(Error::SlowDown))
|
|
.expect_err("entry error should win over cancellation");
|
|
assert!(matches!(err, Error::SlowDown));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_partial_listing_entry_rejects_unresolved_metadata() {
|
|
let generation = OffsetDateTime::now_utc();
|
|
let unresolved_entry = resolve_decommission_partial_listing_entry(
|
|
MetaCacheEntries(vec![None]),
|
|
MetadataResolutionParams {
|
|
dir_quorum: 2,
|
|
obj_quorum: 2,
|
|
bucket: "bucket-a".to_string(),
|
|
..Default::default()
|
|
},
|
|
"bucket-a",
|
|
"prefix/",
|
|
1,
|
|
2,
|
|
3,
|
|
generation,
|
|
)
|
|
.expect_err("unresolved partial listing must fail closed");
|
|
|
|
assert_eq!(unresolved_entry.bucket, "bucket-a");
|
|
assert_eq!(unresolved_entry.object, "prefix/");
|
|
assert_eq!(unresolved_entry.pool_index, 2);
|
|
assert_eq!(unresolved_entry.set_index, 3);
|
|
assert_eq!(unresolved_entry.source_generation, generation);
|
|
assert_eq!(unresolved_entry.candidate_count, 0);
|
|
assert_eq!(unresolved_entry.disk_error_count, 1);
|
|
assert_eq!(unresolved_entry.reason, "metadata_resolution_failed");
|
|
|
|
let message = decommission_unresolved_listing_error(&unresolved_entry).to_string();
|
|
assert!(message.contains("decommission listing could not resolve metadata"));
|
|
assert!(message.contains("bucket-a/prefix/"));
|
|
assert!(message.contains("pool 2 set 3"));
|
|
assert!(message.contains("1 disk error(s)"));
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_entry_ledger_requires_individual_verification_before_completion() {
|
|
let generation = OffsetDateTime::now_utc();
|
|
let mut pool_meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "/data/pool".to_string(),
|
|
last_update: generation,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
dont_save: true,
|
|
};
|
|
let unresolved_entry = DecommissionUnresolvedEntry {
|
|
bucket: "bucket-a".to_string(),
|
|
object: "object-a".to_string(),
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
source_generation: generation,
|
|
candidate_count: 1,
|
|
disk_error_count: 0,
|
|
observed_at: generation,
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
};
|
|
|
|
assert!(
|
|
record_decommission_unresolved_entry(
|
|
&mut pool_meta,
|
|
0,
|
|
generation,
|
|
unresolved_entry.clone(),
|
|
generation + Duration::nanoseconds(1),
|
|
None,
|
|
)
|
|
.expect("active generation should accept unresolved entry")
|
|
);
|
|
let err = reconcile_decommission_unresolved_entries_for_completion(&mut pool_meta, 0, None, None)
|
|
.expect_err("unverified completion must retain unresolved entries");
|
|
assert!(err.to_string().contains("1 unresolved listing entries remain"));
|
|
assert_eq!(
|
|
pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("decommission should exist")
|
|
.unresolved_entries
|
|
.len(),
|
|
1
|
|
);
|
|
|
|
let err = reconcile_decommission_unresolved_entries_for_completion(&mut pool_meta, 0, Some(generation), None)
|
|
.expect_err("a same-generation sweep without entry proof must retain the ledger");
|
|
assert!(err.to_string().contains("not individually verified"));
|
|
|
|
let stale_verified = vec![unresolved_entry.clone()];
|
|
let mut replacement = unresolved_entry;
|
|
replacement.disk_error_count = 2;
|
|
replacement.observed_at = generation + Duration::seconds(1);
|
|
assert!(
|
|
record_decommission_unresolved_entry(
|
|
&mut pool_meta,
|
|
0,
|
|
generation,
|
|
replacement.clone(),
|
|
generation + Duration::nanoseconds(2),
|
|
None,
|
|
)
|
|
.expect("a newer observation should replace the ledger entry")
|
|
);
|
|
let err =
|
|
reconcile_decommission_unresolved_entries_for_completion(&mut pool_meta, 0, Some(generation), Some(&stale_verified))
|
|
.expect_err("stale entry verification must not clear a concurrent replacement");
|
|
assert!(err.to_string().contains("not individually verified"));
|
|
|
|
let verified = vec![replacement];
|
|
reconcile_decommission_unresolved_entries_for_completion(&mut pool_meta, 0, Some(generation), Some(&verified))
|
|
.expect("individually verified entries should reconcile");
|
|
assert!(
|
|
pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("decommission should exist")
|
|
.unresolved_entries
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn final_sweep_persists_unresolved_entry_from_real_listing() {
|
|
let (dirs, store) = metadata_sys::test_support::isolated_store_over_temp_disks().await;
|
|
let bucket = "decommission-final-sweep-unresolved";
|
|
let object = "corrupt-object";
|
|
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
|
store
|
|
.make_bucket(bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("test bucket should be created");
|
|
|
|
let generation = OffsetDateTime::now_utc();
|
|
{
|
|
let mut pool_meta = store.pool_meta.write().await;
|
|
pool_meta.dont_save = false;
|
|
pool_meta.pools[0].decommission = Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
});
|
|
}
|
|
store
|
|
.save_current_pool_meta_for_test(&[0])
|
|
.await
|
|
.expect("active decommission metadata should be persisted before the final sweep");
|
|
|
|
for (disk_index, dir) in dirs.iter().enumerate() {
|
|
let object_dir = dir.path().join(bucket).join(object);
|
|
tokio::fs::create_dir_all(&object_dir)
|
|
.await
|
|
.expect("corrupt object directory should be created");
|
|
tokio::fs::write(object_dir.join(STORAGE_FORMAT_FILE), format!("corrupt-xl-meta-{disk_index}").into_bytes())
|
|
.await
|
|
.expect("divergent corrupt metadata should be written");
|
|
}
|
|
|
|
let err = store
|
|
.check_after_decommission(0, &CancellationToken::new(), generation)
|
|
.await
|
|
.expect_err("real final sweep must fail closed on unresolved listing metadata");
|
|
assert!(
|
|
err.to_string().contains("decommission listing could not resolve metadata"),
|
|
"unexpected final sweep error: {err:?}"
|
|
);
|
|
|
|
let in_memory_entries = store.pool_meta.read().await.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("decommission should remain active")
|
|
.unresolved_entries
|
|
.clone();
|
|
assert_eq!(in_memory_entries.len(), 1);
|
|
let unresolved_entry = &in_memory_entries[0];
|
|
assert_eq!(unresolved_entry.bucket, bucket);
|
|
assert_eq!(unresolved_entry.object, object);
|
|
assert_eq!(unresolved_entry.pool_index, 0);
|
|
assert_eq!(unresolved_entry.set_index, 0);
|
|
assert_eq!(unresolved_entry.source_generation, generation);
|
|
assert_eq!(unresolved_entry.candidate_count, 4);
|
|
assert_eq!(unresolved_entry.disk_error_count, 0);
|
|
assert_eq!(unresolved_entry.reason, "metadata_resolution_failed");
|
|
|
|
let mut restored = PoolMeta::default();
|
|
restored
|
|
.load_no_lock_from_replicas(vec![store.pools[0].clone()])
|
|
.await
|
|
.expect("persisted pool metadata should reload after the final-sweep failure");
|
|
assert_eq!(
|
|
restored.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("reloaded decommission should exist")
|
|
.unresolved_entries,
|
|
in_memory_entries
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decommission_metadata_saves_stay_monotonic_across_clock_rollback_before_terminal_restart() {
|
|
let (_dirs, store) = metadata_sys::test_support::isolated_store_over_temp_disks().await;
|
|
let persisted_floor = store.pool_meta.read().await.pools[0].last_update;
|
|
let generation = persisted_floor
|
|
.checked_add(Duration::seconds(1))
|
|
.expect("test generation should advance the initialized pool metadata");
|
|
let earlier_tick = generation - Duration::nanoseconds(10);
|
|
{
|
|
let mut pool_meta = store.pool_meta.write().await;
|
|
pool_meta.dont_save = false;
|
|
pool_meta.pools[0].last_update = generation;
|
|
pool_meta.pools[0].decommission = Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
});
|
|
}
|
|
store
|
|
.save_current_pool_meta_for_test(&[0])
|
|
.await
|
|
.expect("active decommission metadata should persist before rollback checkpoints");
|
|
|
|
let unresolved_entry = DecommissionUnresolvedEntry {
|
|
bucket: "bucket-a".to_string(),
|
|
object: "object-a".to_string(),
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
source_generation: generation,
|
|
candidate_count: 1,
|
|
disk_error_count: 1,
|
|
observed_at: earlier_tick,
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
};
|
|
{
|
|
let mut pool_meta = store.pool_meta.write().await;
|
|
assert!(
|
|
record_decommission_unresolved_entry(&mut pool_meta, 0, generation, unresolved_entry, earlier_tick, None)
|
|
.expect("unresolved entry should record under active generation")
|
|
);
|
|
assert_eq!(pool_meta.pools[0].last_update, generation + Duration::nanoseconds(1));
|
|
}
|
|
store
|
|
.save_current_pool_meta_for_test(&[0])
|
|
.await
|
|
.expect("rollback unresolved-entry save should not stale-reject");
|
|
store.pool_meta.write().await.pools[0]
|
|
.decommission
|
|
.as_mut()
|
|
.expect("decommission metadata should exist")
|
|
.items_decommissioned = DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD;
|
|
|
|
assert!(
|
|
store
|
|
.save_decommission_progress_checkpoint_at(0, generation, earlier_tick)
|
|
.await
|
|
.expect("rollback progress checkpoint should use a monotonic durable identity")
|
|
);
|
|
let progress_at = generation + Duration::nanoseconds(2);
|
|
assert_eq!(store.pool_meta.read().await.pools[0].last_update, progress_at);
|
|
|
|
let terminal_at = generation + Duration::nanoseconds(3);
|
|
{
|
|
let mut pool_meta = store.pool_meta.write().await;
|
|
assert!(pool_meta.decommission_failed_at_for_test(0, earlier_tick, None));
|
|
assert_eq!(pool_meta.pools[0].last_update, terminal_at);
|
|
}
|
|
store
|
|
.save_current_pool_meta_for_test(&[0])
|
|
.await
|
|
.expect("terminal failure after rollback checkpoints should persist");
|
|
|
|
let mut restored = PoolMeta::default();
|
|
restored
|
|
.load_no_lock_from_replicas(vec![store.pools[0].clone()])
|
|
.await
|
|
.expect("terminal metadata should reload after restart");
|
|
assert_eq!(restored.pools[0].last_update, terminal_at);
|
|
assert!(
|
|
restored.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.is_some_and(|info| info.failed && !info.complete && !info.canceled)
|
|
);
|
|
|
|
let restarted = decommission_worker_test_store(restored, Vec::new());
|
|
let status = restarted.scanner_data_movement_pause_status().await;
|
|
let expected_generation =
|
|
u64::try_from(terminal_at.unix_timestamp_nanos()).expect("fixed positive timestamp should fit generation");
|
|
|
|
assert_eq!(status.movement_generation, expected_generation);
|
|
assert_eq!(status.reasons, vec![crate::store::ScannerDataMovementPauseReason::DecommissionFailed]);
|
|
assert_eq!(restarted.scanner_data_movement_generation(), expected_generation);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unresolved_entry_probe_verifies_absence_on_every_source_disk() {
|
|
let (_dirs, store) = metadata_sys::test_support::isolated_store_over_temp_disks().await;
|
|
let bucket = "decommission-final-sweep-absent-ledger";
|
|
store
|
|
.peer_sys
|
|
.make_bucket(bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("test bucket should be created");
|
|
metadata_sys::init_bucket_metadata_sys(store.clone(), vec![bucket.to_string()]).await;
|
|
|
|
let generation = OffsetDateTime::now_utc();
|
|
let unresolved_entry = DecommissionUnresolvedEntry {
|
|
bucket: bucket.to_string(),
|
|
object: "absent-object".to_string(),
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
source_generation: generation,
|
|
candidate_count: 1,
|
|
disk_error_count: 1,
|
|
observed_at: generation,
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
};
|
|
{
|
|
let mut pool_meta = store.pool_meta.write().await;
|
|
pool_meta.version = POOL_META_VERSION;
|
|
pool_meta.pools[0].decommission = Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
unresolved_entries: vec![unresolved_entry.clone()],
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
assert!(
|
|
store.pools[0].disk_set[0]
|
|
.decommission_unresolved_entry_absent_on_all_disks(0, &unresolved_entry)
|
|
.await
|
|
.expect("all source disks should be readable")
|
|
);
|
|
let verified = vec![unresolved_entry.clone()];
|
|
|
|
let mut pool_meta = store.pool_meta.write().await;
|
|
reconcile_decommission_unresolved_entries_for_completion(&mut pool_meta, 0, Some(generation), Some(&verified))
|
|
.expect("the individually verified entry should reconcile");
|
|
assert!(
|
|
pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("decommission should remain present")
|
|
.unresolved_entries
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_record_decommission_entry_error_cancels_listing_and_preserves_first_error() {
|
|
let entry_error = Arc::new(tokio::sync::Mutex::new(None));
|
|
let rx = CancellationToken::new();
|
|
|
|
assert!(record_decommission_entry_error(&entry_error, &rx, Error::SlowDown).await);
|
|
assert!(!record_decommission_entry_error(&entry_error, &rx, Error::OperationCanceled).await);
|
|
|
|
assert!(rx.is_cancelled());
|
|
assert!(matches!(*entry_error.lock().await, Some(Error::SlowDown)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_record_decommission_entry_error_ignores_already_canceled_listing() {
|
|
let entry_error = Arc::new(tokio::sync::Mutex::new(None));
|
|
let rx = CancellationToken::new();
|
|
rx.cancel();
|
|
|
|
assert!(!record_decommission_entry_error(&entry_error, &rx, Error::SlowDown).await);
|
|
|
|
assert!(entry_error.lock().await.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_entry_verification_requires_every_source_disk() {
|
|
assert!(ensure_decommission_unresolved_verification_disk_count(4, 4, 0, 1).is_ok());
|
|
|
|
let err = ensure_decommission_unresolved_verification_disk_count(4, 3, 0, 1)
|
|
.expect_err("an offline source disk must block ledger reconciliation");
|
|
assert!(err.to_string().contains("requires all 4 source disks, but only 3 are online"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_listing_error_preserves_real_listing_failure() {
|
|
let err = resolve_decommission_listing_error(Some(Error::SlowDown), Some(Error::OperationCanceled))
|
|
.expect("listing failure should be returned");
|
|
assert!(matches!(err, Error::SlowDown));
|
|
|
|
let err = resolve_decommission_listing_error(Some(Error::OperationCanceled), Some(Error::SlowDown))
|
|
.expect("entry failure should be returned");
|
|
assert!(matches!(err, Error::SlowDown));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_check_after_list_result_returns_list_result_without_entry_error() {
|
|
let err = resolve_decommission_check_after_list_result(Err(Error::OperationCanceled), None)
|
|
.expect_err("list result should be preserved without entry error");
|
|
assert!(matches!(err, Error::OperationCanceled));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_pool_meta_reload_result_passthrough_ok() {
|
|
assert!(resolve_decommission_pool_meta_reload_result(Ok(()), "start_decommission").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_pool_meta_reload_result_wraps_error_context() {
|
|
let err = resolve_decommission_pool_meta_reload_result(Err(Error::SlowDown), "decommission_failed for pool 3")
|
|
.expect_err("reload failure should be wrapped with stage context");
|
|
let message = err.to_string();
|
|
assert!(message.contains("decommission pool meta reload failed during decommission_failed for pool 3"));
|
|
assert!(message.contains(Error::SlowDown.to_string().as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_start_decommission_pool_meta_reload_result_returns_failure() {
|
|
let err = resolve_start_decommission_pool_meta_reload_result(Err(Error::other(
|
|
"reload_pool_meta encountered 1 failure(s): peer[0] reload_pool_meta failed",
|
|
)))
|
|
.expect_err("start_decommission must fail when peer pool meta reload fails");
|
|
let message = err.to_string();
|
|
|
|
assert!(message.contains("decommission pool meta reload failed during start_decommission"));
|
|
assert!(message.contains("reload_pool_meta encountered 1 failure(s)"));
|
|
assert!(message.contains("peer[0]"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_listing_worker_result_passthrough_ok() {
|
|
assert!(resolve_decommission_listing_worker_result(2, Ok(Ok(()))).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_decommission_listing_worker_result_passthrough_worker_error() {
|
|
let err = resolve_decommission_listing_worker_result(2, Ok(Err(Error::SlowDown)))
|
|
.expect_err("listing worker error should be returned");
|
|
|
|
assert!(matches!(err, Error::SlowDown));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_resolve_decommission_listing_worker_result_wraps_join_error_context() {
|
|
let join_error = tokio::spawn(async {
|
|
panic!("listing worker panic");
|
|
})
|
|
.await
|
|
.expect_err("panic task should return JoinError");
|
|
|
|
let err = resolve_decommission_listing_worker_result(4, Err(join_error))
|
|
.expect_err("join error should be wrapped with context");
|
|
let message = err.to_string();
|
|
assert!(message.contains("decommission listing worker 4 task join error"));
|
|
assert!(message.contains("panic"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_retry_decommission_listing_respects_attempt_limit_and_bucket_missing() {
|
|
assert!(should_retry_decommission_listing(&Error::SlowDown, 0, 2));
|
|
assert!(!should_retry_decommission_listing(&Error::SlowDown, 1, 2));
|
|
assert!(!should_retry_decommission_listing(
|
|
&StorageError::BucketNotFound("bucket".to_string()),
|
|
0,
|
|
2
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_wait_decommission_retry_backoff_reports_canceled_without_sleeping() {
|
|
let token = CancellationToken::new();
|
|
token.cancel();
|
|
|
|
assert!(wait_decommission_retry_backoff(&token, StdDuration::from_secs(30)).await);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_retry_backoff_delay_grows_linearly() {
|
|
let base = StdDuration::from_millis(100);
|
|
|
|
assert_eq!(decommission_retry_backoff_delay(base, 1), base);
|
|
assert_eq!(decommission_retry_backoff_delay(base, 3), StdDuration::from_millis(300));
|
|
assert_eq!(decommission_retry_backoff_delay(base, usize::MAX), base.saturating_mul(u32::MAX));
|
|
}
|
|
|
|
#[test]
|
|
fn test_source_changed_exhaustion_fails_only_after_pool_limit() {
|
|
assert!(!should_fail_decommission_pool_after_exhausted_source_changed(
|
|
DECOMMISSION_SOURCE_CHANGED_EXHAUSTION_LIMIT
|
|
));
|
|
assert!(should_fail_decommission_pool_after_exhausted_source_changed(
|
|
DECOMMISSION_SOURCE_CHANGED_EXHAUSTION_LIMIT + 1
|
|
));
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn test_run_decommission_listing_with_retry_stops_after_attempt_limit() {
|
|
let attempts = Arc::new(AtomicUsize::new(0));
|
|
let err = run_decommission_listing_with_retry(
|
|
CancellationToken::new(),
|
|
"bucket-a".to_string(),
|
|
noop_decommission_list_callback(),
|
|
1,
|
|
2,
|
|
3,
|
|
{
|
|
let attempts = attempts.clone();
|
|
move |_| {
|
|
let attempts = attempts.clone();
|
|
async move {
|
|
attempts.fetch_add(1, Ordering::SeqCst);
|
|
Err(Error::SlowDown)
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.await
|
|
.expect_err("permanent listing failure must not retry forever");
|
|
|
|
assert_eq!(attempts.load(Ordering::SeqCst), 3);
|
|
assert!(err.to_string().contains("attempt 3/3"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_decommission_listing_with_retry_treats_bucket_missing_as_complete() {
|
|
let attempts = Arc::new(AtomicUsize::new(0));
|
|
run_decommission_listing_with_retry(
|
|
CancellationToken::new(),
|
|
"bucket-a".to_string(),
|
|
noop_decommission_list_callback(),
|
|
1,
|
|
2,
|
|
3,
|
|
{
|
|
let attempts = attempts.clone();
|
|
move |_| {
|
|
let attempts = attempts.clone();
|
|
async move {
|
|
attempts.fetch_add(1, Ordering::SeqCst);
|
|
Err(StorageError::BucketNotFound("bucket-a".to_string()))
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.await
|
|
.expect("missing bucket should keep previous decommission listing behavior");
|
|
|
|
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_count_decommission_version_complete_for_cleanup_safe_ignored_result() {
|
|
assert!(should_count_decommission_version_complete(true, true, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_count_decommission_version_complete_rejects_skip_only_ignored_result() {
|
|
assert!(!should_count_decommission_version_complete(true, false, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_count_decommission_version_complete_for_completed_result() {
|
|
assert!(should_count_decommission_version_complete(false, false, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_count_decommission_version_complete_rejects_failed_result() {
|
|
assert!(!should_count_decommission_version_complete(false, false, true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_cleanup_decommission_source_entry_accepts_all_versions_completed() {
|
|
assert!(should_cleanup_decommission_source_entry(3, 3, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_cleanup_decommission_source_entry_accepts_migrated_and_safely_expired_versions() {
|
|
assert!(should_cleanup_decommission_source_entry(1, 2, 1));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_cleanup_decommission_source_entry_accepts_versions_only_safely_expired_by_lifecycle() {
|
|
assert!(should_cleanup_decommission_source_entry(0, 2, 2));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_cleanup_decommission_source_entry_rejects_object_lock_retained_version() {
|
|
assert!(!should_cleanup_decommission_source_entry(1, 2, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_cleanup_decommission_source_entry_rejects_replication_pending_version() {
|
|
assert!(!should_cleanup_decommission_source_entry(2, 3, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_cleanup_decommission_source_entry_rejects_counter_overrun() {
|
|
assert!(!should_cleanup_decommission_source_entry(2, 2, 1));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_update_after_rejects_out_of_range_index() {
|
|
let mut meta = PoolMeta::default();
|
|
let err = meta
|
|
.update_after(1, Duration::seconds(1))
|
|
.expect_err("out-of-range index should fail");
|
|
assert!(err.to_string().contains("invalid decommission pool index 1 for 0 pools"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_update_after_rejects_when_decommission_missing() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = meta
|
|
.update_after(0, Duration::seconds(1))
|
|
.expect_err("pool without decommission should fail");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to update decommission metadata timestamp: decommission metadata not initialized")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_track_decommission_stage_does_not_advance_checkpoint_state() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
items_decommissioned: 3,
|
|
items_decommission_failed: 2,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
track_decommission_current_object_stage(&mut meta, 0, "bucket", "object", "migrate_object")
|
|
.expect("valid decommission progress should be tracked");
|
|
|
|
assert_eq!(meta.pools[0].last_update, OffsetDateTime::UNIX_EPOCH);
|
|
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
|
|
assert_eq!(info.items_since_last_progress_save(), 5);
|
|
assert_eq!(info.stage, "migrate_object");
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_update_after_skips_before_time_and_item_thresholds() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::now_utc(),
|
|
decommission: Some(PoolDecommissionInfo {
|
|
items_decommissioned: DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD - 1,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let saved = meta
|
|
.update_after(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL)
|
|
.expect("valid decommission state should update");
|
|
|
|
assert!(!saved);
|
|
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
|
|
assert_eq!(info.items_since_last_progress_save(), DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD - 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_update_after_requests_save_when_item_threshold_reached() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::now_utc(),
|
|
decommission: Some(PoolDecommissionInfo {
|
|
items_decommissioned: DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let saved = meta
|
|
.update_after(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL)
|
|
.expect("item threshold should save progress");
|
|
|
|
assert!(saved);
|
|
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
|
|
assert_eq!(info.items_since_last_progress_save(), DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_update_after_requests_save_when_time_threshold_reached() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::now_utc() - DECOMMISSION_PROGRESS_SAVE_INTERVAL,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
items_decommissioned: 1,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let saved = meta
|
|
.update_after(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL)
|
|
.expect("time threshold should save progress");
|
|
|
|
assert!(saved);
|
|
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
|
|
assert_eq!(info.items_since_last_progress_save(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_update_after_does_not_advance_last_update_before_save() {
|
|
let last_update = OffsetDateTime::UNIX_EPOCH;
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(last_update),
|
|
items_decommissioned: DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(
|
|
meta.update_after(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL)
|
|
.expect("item threshold should request a checkpoint")
|
|
);
|
|
assert_eq!(meta.pools[0].last_update, last_update);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_progress_checkpoint_commits_exact_snapshot_watermark() {
|
|
let start_time = OffsetDateTime::UNIX_EPOCH;
|
|
let checkpoint_at = start_time + Duration::seconds(30);
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: start_time,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(start_time),
|
|
items_decommissioned: DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let checkpoint = meta
|
|
.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at, None)
|
|
.expect("valid decommission state should produce a checkpoint")
|
|
.expect("item threshold should produce a checkpoint");
|
|
meta.count_item(0, 1, false);
|
|
|
|
assert!(meta.commit_decommission_progress_checkpoint(0, checkpoint));
|
|
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
|
|
assert_eq!(info.progress_save_item_baseline, checkpoint.counted_items);
|
|
assert_eq!(info.items_since_last_progress_save(), 1);
|
|
assert_eq!(meta.pools[0].last_update, checkpoint_at);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_progress_checkpoint_backoff_does_not_advance_baseline() {
|
|
let start_time = OffsetDateTime::UNIX_EPOCH;
|
|
let checkpoint_at = start_time + Duration::seconds(30);
|
|
let retry_after = checkpoint_at + DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF;
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: start_time,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(start_time),
|
|
items_decommissioned: DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let checkpoint = meta
|
|
.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at, None)
|
|
.expect("valid decommission state should produce a checkpoint")
|
|
.expect("item threshold should produce a checkpoint");
|
|
meta.defer_decommission_progress_checkpoint(0, checkpoint, retry_after);
|
|
|
|
assert!(
|
|
meta.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at, None)
|
|
.expect("retry backoff check should succeed")
|
|
.is_none()
|
|
);
|
|
assert_eq!(meta.pools[0].last_update, start_time);
|
|
assert_eq!(
|
|
meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("decommission info should exist")
|
|
.progress_save_item_baseline,
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_progress_checkpoint_count_scales_with_threshold() {
|
|
let start_time = OffsetDateTime::UNIX_EPOCH;
|
|
let checkpoint_at = start_time;
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: start_time,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(start_time),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let mut checkpoint_count = 0;
|
|
|
|
for _ in 0..(DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD * 10) {
|
|
meta.count_item(0, 1, false);
|
|
if let Some(checkpoint) = meta
|
|
.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at, None)
|
|
.expect("valid decommission state should produce a checkpoint")
|
|
{
|
|
checkpoint_count += 1;
|
|
assert!(meta.commit_decommission_progress_checkpoint(0, checkpoint));
|
|
}
|
|
}
|
|
|
|
assert_eq!(checkpoint_count, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_not_rebalancing_rejects_running_rebalance() {
|
|
let err = ensure_decommission_not_rebalancing(true).expect_err("rebalance running should be rejected");
|
|
assert!(matches!(err, Error::RebalanceAlreadyRunning));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_not_rebalancing_allows_idle() {
|
|
assert!(ensure_decommission_not_rebalancing(false).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_rebalance_meta_allowed_rejects_active_rebalance() {
|
|
let meta = RebalanceMeta {
|
|
pool_stats: vec![RebalanceStats {
|
|
participating: true,
|
|
info: RebalanceInfo {
|
|
status: RebalStatus::Started,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = ensure_decommission_start_rebalance_meta_allowed(Some(&meta))
|
|
.expect_err("persisted active rebalance should block decommission start");
|
|
|
|
assert!(matches!(err, Error::RebalanceAlreadyRunning));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_rebalance_meta_allowed_rejects_stopping_rebalance() {
|
|
let meta = RebalanceMeta {
|
|
pool_stats: vec![RebalanceStats {
|
|
info: RebalanceInfo {
|
|
status: RebalStatus::Started,
|
|
stopping: true,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = ensure_decommission_start_rebalance_meta_allowed(Some(&meta))
|
|
.expect_err("persisted stopping rebalance should block decommission start");
|
|
|
|
assert!(matches!(err, Error::RebalanceAlreadyRunning));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_rebalance_meta_allowed_allows_terminal_or_missing_rebalance() {
|
|
let terminal_meta = RebalanceMeta {
|
|
pool_stats: vec![RebalanceStats {
|
|
participating: true,
|
|
info: RebalanceInfo {
|
|
status: RebalStatus::Completed,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(ensure_decommission_start_rebalance_meta_allowed(Some(&terminal_meta)).is_ok());
|
|
assert!(ensure_decommission_start_rebalance_meta_allowed(None).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_local_decommission_pool_leaders_allows_local_first_endpoint() {
|
|
let endpoints = EndpointServerPools::from(vec![
|
|
decommission_test_pool_endpoint(0, false),
|
|
decommission_test_pool_endpoint(1, true),
|
|
]);
|
|
|
|
assert!(ensure_local_decommission_pool_leaders(&endpoints, &[1]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_local_decommission_pool_leaders_rejects_remote_first_endpoint() {
|
|
let endpoints = EndpointServerPools::from(vec![decommission_test_pool_endpoint(0, false)]);
|
|
|
|
let err = ensure_local_decommission_pool_leaders(&endpoints, &[0])
|
|
.expect_err("remote first endpoint should reject local decommission start");
|
|
|
|
assert!(err.to_string().contains("must run on the pool first endpoint"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_local_decommission_pool_leaders_rejects_empty_endpoints() {
|
|
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 = ensure_local_decommission_pool_leaders(&endpoints, &[0])
|
|
.expect_err("pool without endpoints should reject local decommission start");
|
|
|
|
assert!(err.to_string().contains("has no configured endpoints"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_meta_bucket_options_are_idempotent() {
|
|
let opts = decommission_meta_bucket_options();
|
|
|
|
assert!(opts.force_create);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_decommission_active_true_only_when_not_terminal() {
|
|
assert!(is_decommission_active(false, false, false));
|
|
assert!(!is_decommission_active(true, false, false));
|
|
assert!(!is_decommission_active(false, true, false));
|
|
assert!(!is_decommission_active(false, false, true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_generation_rejects_stale_or_queued_workers() {
|
|
let generation = OffsetDateTime::UNIX_EPOCH;
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: generation,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(ensure_decommission_generation(&meta, 0, generation).is_ok());
|
|
assert!(ensure_decommission_generation(&meta, 0, generation + Duration::seconds(1)).is_err());
|
|
|
|
meta.pools[0]
|
|
.decommission
|
|
.as_mut()
|
|
.expect("decommission metadata should exist")
|
|
.queued = true;
|
|
assert!(ensure_decommission_generation(&meta, 0, generation).is_err());
|
|
|
|
let replacement_generation = generation + Duration::seconds(2);
|
|
let info = meta.pools[0]
|
|
.decommission
|
|
.as_mut()
|
|
.expect("decommission metadata should exist");
|
|
info.queued = false;
|
|
info.start_time = Some(replacement_generation);
|
|
assert!(ensure_decommission_generation(&meta, 0, generation).is_err());
|
|
assert!(ensure_decommission_generation(&meta, 0, replacement_generation).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_has_active_decommission_counts_running_and_queued_states() {
|
|
let active_meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let queued_meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
queued: true,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(pool_meta_has_active_decommission(&active_meta));
|
|
assert!(pool_meta_has_active_decommission(&queued_meta));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_has_active_decommission_ignores_capacity_placeholder() {
|
|
let meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
total_size: 100,
|
|
current_size: 75,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(!pool_meta_has_active_decommission(&meta));
|
|
assert!(!meta.is_suspended(0));
|
|
assert_eq!(decommission_start_pool_state(meta.pools.first()), DecommissionStartPoolState::Active);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_has_active_decommission_ignores_terminal_states() {
|
|
let terminal_meta = PoolMeta {
|
|
pools: vec![
|
|
PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
PoolStatus {
|
|
id: 2,
|
|
cmd_line: "pool-2".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
canceled: true,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(!pool_meta_has_active_decommission(&terminal_meta));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_allowed_rejects_missing_pool() {
|
|
let err =
|
|
ensure_decommission_start_allowed(DecommissionStartPoolState::Missing).expect_err("missing pool should be invalid");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to start decommission: target pool was not found")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_allowed_rejects_running_state() {
|
|
let err = ensure_decommission_start_allowed(DecommissionStartPoolState::Decommissioning)
|
|
.expect_err("active decommission should be rejected");
|
|
assert!(matches!(err, Error::DecommissionAlreadyRunning));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_allowed_rejects_completed_state() {
|
|
let err = ensure_decommission_start_allowed(DecommissionStartPoolState::Decommissioned)
|
|
.expect_err("completed decommission should be rejected");
|
|
assert!(err.to_string().contains("target pool is already decommissioned"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_allowed_rejects_blocked_state() {
|
|
let err = ensure_decommission_start_allowed(DecommissionStartPoolState::Blocked)
|
|
.expect_err("blocked decommission should be rejected");
|
|
assert!(err.to_string().contains("target pool decommission is blocked"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_allowed_allows_active_state() {
|
|
assert!(ensure_decommission_start_allowed(DecommissionStartPoolState::Active).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_allowed_allows_retryable_state() {
|
|
assert!(ensure_decommission_start_allowed(DecommissionStartPoolState::Retryable).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_start_pool_state_reports_missing_pool() {
|
|
assert_eq!(decommission_start_pool_state(None), DecommissionStartPoolState::Missing);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_start_pool_state_reports_idle_pool_without_decommission_info() {
|
|
let pool = PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
};
|
|
|
|
assert_eq!(decommission_start_pool_state(Some(&pool)), DecommissionStartPoolState::Active);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_start_pool_state_reports_decommissioning_pool_when_not_terminal() {
|
|
let pool = PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
complete: false,
|
|
failed: false,
|
|
canceled: false,
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
assert_eq!(decommission_start_pool_state(Some(&pool)), DecommissionStartPoolState::Decommissioning);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_start_pool_state_reports_canceled_pool_as_blocked() {
|
|
let pool = PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
complete: false,
|
|
failed: false,
|
|
canceled: true,
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
assert_eq!(decommission_start_pool_state(Some(&pool)), DecommissionStartPoolState::Blocked);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_start_pool_state_reports_failed_pool_as_blocked() {
|
|
let pool = PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
assert_eq!(decommission_start_pool_state(Some(&pool)), DecommissionStartPoolState::Blocked);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_start_pool_state_reports_terminal_pool_with_unresolved_entries_as_retryable() {
|
|
let pool = PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
unresolved_entries: vec![DecommissionUnresolvedEntry {
|
|
bucket: "bucket-a".to_string(),
|
|
object: "object-a".to_string(),
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
source_generation: OffsetDateTime::UNIX_EPOCH,
|
|
candidate_count: 1,
|
|
disk_error_count: 1,
|
|
observed_at: OffsetDateTime::UNIX_EPOCH,
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
assert_eq!(decommission_start_pool_state(Some(&pool)), DecommissionStartPoolState::Retryable);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_start_pool_state_reports_completed_pool() {
|
|
let pool = PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
assert_eq!(decommission_start_pool_state(Some(&pool)), DecommissionStartPoolState::Decommissioned);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_keeps_active_pool_rejects_last_active_pool() {
|
|
let meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = ensure_decommission_start_keeps_active_pool(&meta, &[0]).expect_err("last active pool should be rejected");
|
|
|
|
assert!(err.to_string().contains("at least one active pool must remain"));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_capacity_model_selection_uses_v2_only_with_a_live_fleet_proof() {
|
|
let meta = PoolMeta::default();
|
|
|
|
assert_eq!(
|
|
select_decommission_capacity_model(&meta, false).expect("a proofless empty cohort should remain compatible"),
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION
|
|
);
|
|
assert_eq!(
|
|
select_decommission_capacity_model(&meta, true).expect("an all-v4 proof should authorize the target fence"),
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_capacity_model_selection_keeps_an_active_v1_cohort_sticky() {
|
|
let meta = decommission_test_active_model_meta(&[DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION]);
|
|
|
|
assert_eq!(
|
|
select_decommission_capacity_model(&meta, true).expect("a fleet upgrade must not change an active v1 lock model"),
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_capacity_model_selection_never_downgrades_an_active_v2_cohort() {
|
|
let meta = decommission_test_active_model_meta(&[DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION]);
|
|
|
|
assert_eq!(
|
|
select_decommission_capacity_model(&meta, true).expect("a live proof should admit another v2 reservation"),
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION
|
|
);
|
|
let err = select_decommission_capacity_model(&meta, false)
|
|
.expect_err("proof loss must block new admission instead of falling back to the global lock model");
|
|
assert!(err.to_string().contains("active per-target capacity cohort"));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_capacity_model_selection_rejects_a_mixed_active_cohort() {
|
|
let meta = decommission_test_active_model_meta(&[
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION,
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION,
|
|
]);
|
|
|
|
let err = select_decommission_capacity_model(&meta, true)
|
|
.expect_err("mixed global and per-target lock models cannot be made safe by a fleet proof");
|
|
assert!(err.to_string().contains("mixed lock models"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_target_capacity_allows_sufficient_free_space() {
|
|
let meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
|
|
..Default::default()
|
|
};
|
|
let capacity_infos = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, DecommissionErasureLayout { data: 4, parity: 2 }, 100, 700, 600),
|
|
DecommissionPoolCapacityInfo::for_test(1, DecommissionErasureLayout { data: 4, parity: 4 }, 1_600, 2_000, 400),
|
|
];
|
|
|
|
assert!(ensure_decommission_start_target_capacity(&meta, &[0], &capacity_infos, true).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_target_capacity_rejects_insufficient_free_space() {
|
|
let meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
|
|
..Default::default()
|
|
};
|
|
let capacity_infos = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, DecommissionErasureLayout { data: 4, parity: 2 }, 100, 700, 600),
|
|
DecommissionPoolCapacityInfo::for_test(1, DecommissionErasureLayout { data: 4, parity: 4 }, 1_599, 2_000, 401),
|
|
];
|
|
|
|
let err = ensure_decommission_start_target_capacity(&meta, &[0], &capacity_infos, true)
|
|
.expect_err("target physical free capacity below the modeled peak should be rejected");
|
|
|
|
assert!(err.to_string().contains("insufficient reserved physical target capacity"));
|
|
assert!(err.to_string().contains("requires 1600 bytes, but 1599 bytes are available"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_target_capacity_ignores_non_active_target_pool() {
|
|
let meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![
|
|
decommission_test_pool_status(0, None),
|
|
decommission_test_pool_status(
|
|
1,
|
|
Some(PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(2, None),
|
|
],
|
|
..Default::default()
|
|
};
|
|
let capacity_infos = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, DecommissionErasureLayout { data: 4, parity: 2 }, 100, 700, 600),
|
|
DecommissionPoolCapacityInfo::for_test(1, DecommissionErasureLayout { data: 4, parity: 4 }, 10_000, 10_000, 0),
|
|
DecommissionPoolCapacityInfo::for_test(2, DecommissionErasureLayout { data: 4, parity: 4 }, 1_599, 2_000, 401),
|
|
];
|
|
|
|
let err = ensure_decommission_start_target_capacity(&meta, &[0], &capacity_infos, true)
|
|
.expect_err("completed pools must not contribute target free capacity");
|
|
|
|
assert!(err.to_string().contains("requires 1600 bytes, but 1599 bytes are available"));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_capacity_model_converts_different_source_and_target_parity() {
|
|
let now = OffsetDateTime::UNIX_EPOCH;
|
|
let high_target_parity = build_decommission_capacity_reservation(
|
|
DecommissionPoolCapacityInfo::for_test(0, DecommissionErasureLayout { data: 4, parity: 2 }, 0, 600, 600),
|
|
DecommissionErasureLayout { data: 4, parity: 4 },
|
|
uuid::Uuid::new_v4(),
|
|
1,
|
|
now,
|
|
)
|
|
.expect("the source and target layouts should be valid");
|
|
assert_eq!(high_target_parity.source_data_equivalent_bytes, 400);
|
|
assert_eq!(high_target_parity.predicted_physical_bytes, 800);
|
|
assert_eq!(high_target_parity.temporary_physical_bytes, 800);
|
|
assert_eq!(high_target_parity.peak_physical_bytes, 1_600);
|
|
|
|
let low_target_parity = build_decommission_capacity_reservation(
|
|
DecommissionPoolCapacityInfo::for_test(0, DecommissionErasureLayout { data: 4, parity: 4 }, 0, 800, 800),
|
|
DecommissionErasureLayout { data: 4, parity: 2 },
|
|
uuid::Uuid::new_v4(),
|
|
2,
|
|
now,
|
|
)
|
|
.expect("the inverse source and target layouts should be valid");
|
|
assert_eq!(low_target_parity.source_data_equivalent_bytes, 400);
|
|
assert_eq!(low_target_parity.predicted_physical_bytes, 600);
|
|
assert_eq!(low_target_parity.peak_physical_bytes, 1_200);
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_batch_persists_one_replayable_operation_identity_and_generation() {
|
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(1);
|
|
let operation_id = uuid::Uuid::new_v4();
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let capacity_infos = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 10, 10),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, 20, 20),
|
|
DecommissionPoolCapacityInfo::for_test(2, layout, 60, 60, 0),
|
|
];
|
|
let mut meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![
|
|
decommission_test_pool_status(0, None),
|
|
decommission_test_pool_status(1, None),
|
|
decommission_test_pool_status(2, None),
|
|
],
|
|
..Default::default()
|
|
};
|
|
meta.decommission(0, capacity_infos[0].space).unwrap();
|
|
meta.queue_decommission(1, capacity_infos[1].space).unwrap();
|
|
|
|
reserve_decommission_start_target_capacity(
|
|
&mut meta,
|
|
&[0, 1],
|
|
&capacity_infos,
|
|
operation_id,
|
|
17,
|
|
now,
|
|
DECOMMISSION_CAPACITY_MODEL_VERSION,
|
|
)
|
|
.expect("the batch reservation should fit exactly");
|
|
for idx in [0, 1] {
|
|
let reservation = meta.pools[idx]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("each source pool should carry the batch reservation identity");
|
|
assert_eq!(reservation.operation_id, operation_id);
|
|
assert_eq!(reservation.generation, 17);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_capacity_model_reserves_versions_delete_markers_and_temporary_copies_from_physical_usage() {
|
|
let reservation = build_decommission_capacity_reservation(
|
|
DecommissionPoolCapacityInfo::for_test(0, DecommissionErasureLayout { data: 2, parity: 2 }, 0, 1_024, 1_024),
|
|
DecommissionErasureLayout { data: 2, parity: 2 },
|
|
uuid::Uuid::new_v4(),
|
|
1,
|
|
OffsetDateTime::UNIX_EPOCH,
|
|
)
|
|
.expect("physical source usage should produce a reservation");
|
|
|
|
assert_eq!(reservation.source_physical_bytes, 1_024);
|
|
assert_eq!(reservation.predicted_physical_bytes, 1_024);
|
|
assert_eq!(reservation.temporary_copies, 1);
|
|
assert_eq!(reservation.peak_physical_bytes, 2_048);
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_runtime_capacity_tracks_own_target_consumption_but_rejects_external_drop() {
|
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let initial = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 60, 60, 0),
|
|
];
|
|
let mut meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
|
|
..Default::default()
|
|
};
|
|
meta.decommission(0, initial[0].space).unwrap();
|
|
reserve_decommission_start_target_capacity(
|
|
&mut meta,
|
|
&[0],
|
|
&initial,
|
|
uuid::Uuid::new_v4(),
|
|
1,
|
|
now,
|
|
DECOMMISSION_CAPACITY_MODEL_VERSION,
|
|
)
|
|
.expect("the initial target capacity should fit exactly");
|
|
|
|
let mutation_id = uuid::Uuid::from_u128(1);
|
|
reserve_decommission_target_pending(&mut meta, 0, 1, 10, mutation_id, now + Duration::seconds(1))
|
|
.expect("the target intent should be durable before its write");
|
|
let own_consumption = vec![initial[0], DecommissionPoolCapacityInfo::for_test(1, layout, 50, 60, 10)];
|
|
ensure_decommission_capacity_reservations_available(&meta, &own_consumption, "restart")
|
|
.expect("a persisted target intent must recognize its own write after restart");
|
|
resolve_decommission_target_pending(&mut meta, 0, 1, 10, mutation_id)
|
|
.expect("the persisted target intent should resolve at commit");
|
|
record_decommission_target_consumption(
|
|
&mut meta,
|
|
0,
|
|
1,
|
|
DecommissionTargetConsumption {
|
|
committed_data_bytes: 10,
|
|
target_physical_bytes: 10,
|
|
observed_physical_bytes: 10,
|
|
},
|
|
mutation_id,
|
|
now + Duration::seconds(1),
|
|
)
|
|
.expect("the operation's own target consumption should be persisted");
|
|
ensure_decommission_capacity_reservations_available(&meta, &own_consumption, "migration")
|
|
.expect("the operation's own committed target bytes must not look like external capacity loss");
|
|
|
|
let dropped = vec![initial[0], DecommissionPoolCapacityInfo::for_test(1, layout, 39, 60, 21)];
|
|
let err = ensure_decommission_capacity_reservations_available(&meta, &dropped, "migration")
|
|
.expect_err("runtime migration must stop after a sudden competing write consumes reserved capacity");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("requires 40 physical bytes, but only 39 bytes remain")
|
|
);
|
|
assert!(is_decommission_capacity_blocked_error(&err));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_target_capacity_reuses_only_matching_pending_intent() {
|
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let initial = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 60, 60, 0),
|
|
];
|
|
let mut meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
|
|
..Default::default()
|
|
};
|
|
meta.decommission(0, initial[0].space).unwrap();
|
|
reserve_decommission_start_target_capacity(
|
|
&mut meta,
|
|
&[0],
|
|
&initial,
|
|
uuid::Uuid::new_v4(),
|
|
1,
|
|
now,
|
|
DECOMMISSION_CAPACITY_MODEL_VERSION,
|
|
)
|
|
.expect("the initial target capacity should fit exactly");
|
|
|
|
let first_mutation_id = uuid::Uuid::from_u128(1);
|
|
let second_mutation_id = uuid::Uuid::from_u128(2);
|
|
reserve_decommission_target_pending(&mut meta, 0, 1, 10, first_mutation_id, now + Duration::seconds(1))
|
|
.expect("the first mutation should persist its target intent");
|
|
assert_eq!(
|
|
reserve_decommission_target_pending(&mut meta, 0, 1, 10, first_mutation_id, now + Duration::seconds(2))
|
|
.expect("the same mutation should reuse its persisted target intent"),
|
|
0
|
|
);
|
|
let err = reserve_decommission_target_pending(&mut meta, 0, 1, 10, second_mutation_id, now + Duration::seconds(2))
|
|
.expect_err("a second mutation must not reuse the first mutation's pending intent");
|
|
assert!(is_decommission_capacity_blocked_error(&err));
|
|
assert!(err.to_string().contains("unresolved target capacity intent"));
|
|
|
|
resolve_decommission_target_pending(&mut meta, 0, 1, 10, first_mutation_id)
|
|
.expect("the first mutation should finalize its intent");
|
|
record_decommission_target_consumption(
|
|
&mut meta,
|
|
0,
|
|
1,
|
|
DecommissionTargetConsumption {
|
|
committed_data_bytes: 10,
|
|
target_physical_bytes: 10,
|
|
observed_physical_bytes: 10,
|
|
},
|
|
first_mutation_id,
|
|
now + Duration::seconds(2),
|
|
)
|
|
.expect("the first mutation's consumption should be durable");
|
|
reserve_decommission_target_pending(&mut meta, 0, 1, 10, second_mutation_id, now + Duration::seconds(3))
|
|
.expect("the second mutation should retry only after the first intent is finalized");
|
|
assert_eq!(
|
|
meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("the reservation should remain present")
|
|
.pending_target_physical_bytes,
|
|
10
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_target_capacity_allows_same_mutation_to_grow_its_pending_stage() {
|
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let initial = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 60, 60, 0),
|
|
];
|
|
let mut meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
|
|
..Default::default()
|
|
};
|
|
meta.decommission(0, initial[0].space).unwrap();
|
|
reserve_decommission_start_target_capacity(
|
|
&mut meta,
|
|
&[0],
|
|
&initial,
|
|
uuid::Uuid::new_v4(),
|
|
1,
|
|
now,
|
|
DECOMMISSION_CAPACITY_MODEL_VERSION,
|
|
)
|
|
.expect("the initial target capacity should fit exactly");
|
|
|
|
let mutation_id = uuid::Uuid::from_u128(1);
|
|
assert_eq!(
|
|
reserve_decommission_target_pending(&mut meta, 0, 1, 1, mutation_id, now + Duration::seconds(1))
|
|
.expect("the initial multipart stage should persist its one-byte intent"),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
reserve_decommission_target_pending(&mut meta, 0, 1, 10, mutation_id, now + Duration::seconds(2))
|
|
.expect("the same mutation should grow its intent for the commit stage"),
|
|
9
|
|
);
|
|
assert_eq!(
|
|
meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("the reservation should remain present")
|
|
.targets[0]
|
|
.pending_physical_bytes,
|
|
10
|
|
);
|
|
assert_eq!(
|
|
reserve_decommission_target_pending(&mut meta, 0, 1, 1, mutation_id, now + Duration::seconds(3))
|
|
.expect("a retry of an earlier stage must reuse the larger same-mutation intent"),
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_capacity_mutation_identity_survives_lease_nonce_rotation() {
|
|
let owner = DecommissionCapacityOwner {
|
|
source_pool_index: 0,
|
|
operation_id: uuid::Uuid::from_u128(1),
|
|
generation: 2,
|
|
owner_nonce: uuid::Uuid::from_u128(3),
|
|
mutation_id: None,
|
|
};
|
|
let recovered_owner = DecommissionCapacityOwner {
|
|
owner_nonce: uuid::Uuid::from_u128(4),
|
|
..owner
|
|
};
|
|
let first = decommission_capacity_mutation_id(owner, "bucket", "object", Some("version"), false, None);
|
|
let recovered = decommission_capacity_mutation_id(recovered_owner, "bucket", "object", Some("version"), false, None);
|
|
assert_eq!(first, recovered, "a lease nonce rotation must not change the mutation identity");
|
|
}
|
|
|
|
#[test]
|
|
fn exact_delete_capacity_plan_requires_identity_and_exact_size() {
|
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let capacity_infos = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 60, 60, 0),
|
|
];
|
|
let mut meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
|
|
..Default::default()
|
|
};
|
|
meta.decommission(0, capacity_infos[0].space).unwrap();
|
|
reserve_decommission_start_target_capacity(
|
|
&mut meta,
|
|
&[0],
|
|
&capacity_infos,
|
|
uuid::Uuid::new_v4(),
|
|
1,
|
|
now,
|
|
DECOMMISSION_CAPACITY_MODEL_VERSION,
|
|
)
|
|
.expect("the exact-delete test reservation should fit");
|
|
let exact = ObjectInfo {
|
|
bucket: "bucket".to_string(),
|
|
name: "object".to_string(),
|
|
version_id: Some(uuid::Uuid::from_u128(7)),
|
|
mod_time: Some(now),
|
|
size: 10,
|
|
..Default::default()
|
|
};
|
|
let owner = {
|
|
let reservation = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("the exact-delete test reservation should exist");
|
|
DecommissionCapacityOwner {
|
|
source_pool_index: 0,
|
|
operation_id: reservation.operation_id,
|
|
generation: reservation.generation,
|
|
owner_nonce: reservation.owner_nonce,
|
|
mutation_id: None,
|
|
}
|
|
};
|
|
let version_id = exact.version_id.map(|version_id| version_id.to_string());
|
|
let mutation_id = decommission_capacity_mutation_id(
|
|
owner,
|
|
&exact.bucket,
|
|
&exact.name,
|
|
version_id.as_deref(),
|
|
exact.delete_marker,
|
|
exact.mod_time,
|
|
);
|
|
reserve_decommission_target_pending(&mut meta, 0, 1, 10, mutation_id, now + Duration::seconds(1))
|
|
.expect("the exact-delete test intent should be reserved");
|
|
|
|
let plan = plan_exact_delete_capacity_reconciliations(&meta, &exact.name, &exact)
|
|
.expect("the exact identity should match the pending intent");
|
|
assert_eq!(plan.len(), 1);
|
|
assert_eq!(plan[0].source_pool_index, 0);
|
|
assert_eq!(plan[0].target_pool_index, 1);
|
|
assert_eq!(plan[0].expected_data_bytes, 10);
|
|
assert_eq!(plan[0].expected_target_physical_bytes, 10);
|
|
|
|
let mismatched_size = ObjectInfo {
|
|
size: 9,
|
|
..exact.clone()
|
|
};
|
|
let mismatched_size = plan_exact_delete_capacity_reconciliations(&meta, &mismatched_size.name, &mismatched_size)
|
|
.expect_err("a different exact size must not consume the pending intent");
|
|
assert!(mismatched_size.to_string().contains("does not match the exact object size"));
|
|
|
|
let directory_exact = ObjectInfo {
|
|
name: "directory/".to_string(),
|
|
..exact.clone()
|
|
};
|
|
let internal_directory = rustfs_utils::path::encode_dir_object(&directory_exact.name);
|
|
let internal_directory_mutation_id = decommission_capacity_mutation_id(
|
|
owner,
|
|
&directory_exact.bucket,
|
|
&internal_directory,
|
|
version_id.as_deref(),
|
|
directory_exact.delete_marker,
|
|
directory_exact.mod_time,
|
|
);
|
|
meta.pools[0]
|
|
.decommission
|
|
.as_mut()
|
|
.and_then(|info| info.capacity_reservation.as_mut())
|
|
.expect("the exact-delete test reservation should exist")
|
|
.targets[0]
|
|
.pending_mutation_id = Some(internal_directory_mutation_id);
|
|
let directory_plan = plan_exact_delete_capacity_reconciliations(&meta, &internal_directory, &directory_exact)
|
|
.expect("an internally encoded directory intent should match its logical exact object");
|
|
assert_eq!(directory_plan[0].mutation_id, internal_directory_mutation_id);
|
|
|
|
let logical_directory_mutation_id = decommission_capacity_mutation_id(
|
|
owner,
|
|
&directory_exact.bucket,
|
|
&directory_exact.name,
|
|
version_id.as_deref(),
|
|
directory_exact.delete_marker,
|
|
directory_exact.mod_time,
|
|
);
|
|
meta.pools[0]
|
|
.decommission
|
|
.as_mut()
|
|
.and_then(|info| info.capacity_reservation.as_mut())
|
|
.expect("the exact-delete test reservation should exist")
|
|
.targets[0]
|
|
.pending_mutation_id = Some(logical_directory_mutation_id);
|
|
let directory_plan = plan_exact_delete_capacity_reconciliations(&meta, &internal_directory, &directory_exact)
|
|
.expect("a logical directory intent should match its internally encoded delete path");
|
|
assert_eq!(directory_plan[0].mutation_id, logical_directory_mutation_id);
|
|
|
|
meta.pools[0]
|
|
.decommission
|
|
.as_mut()
|
|
.and_then(|info| info.capacity_reservation.as_mut())
|
|
.expect("the exact-delete test reservation should exist")
|
|
.targets[0]
|
|
.pending_mutation_id = None;
|
|
let unidentified = plan_exact_delete_capacity_reconciliations(&meta, &exact.name, &exact)
|
|
.expect_err("an unidentified pending intent must fail closed");
|
|
assert!(unidentified.to_string().contains("without an object identity"));
|
|
|
|
let mut opts = ObjectOptions::default();
|
|
let unfenced = ensure_exact_delete_capacity_namespace_fences(&opts, &exact.bucket, &exact.name)
|
|
.expect_err("capacity reconciliation must reject a missing object namespace fence");
|
|
assert!(unfenced.to_string().contains("requires an object namespace fence"));
|
|
opts.ensure_namespace_lock_fence();
|
|
ensure_exact_delete_capacity_namespace_fences(&opts, &exact.bucket, &exact.name)
|
|
.expect("a live object namespace fence should admit capacity reconciliation");
|
|
}
|
|
|
|
#[test]
|
|
fn ordinary_write_admission_cannot_race_into_a_reserved_target() {
|
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let capacity_infos = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 100, 100, 0),
|
|
];
|
|
let mut meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
|
|
..Default::default()
|
|
};
|
|
meta.decommission(0, capacity_infos[0].space).unwrap();
|
|
reserve_decommission_start_target_capacity(
|
|
&mut meta,
|
|
&[0],
|
|
&capacity_infos,
|
|
uuid::Uuid::new_v4(),
|
|
1,
|
|
now,
|
|
DECOMMISSION_CAPACITY_MODEL_VERSION,
|
|
)
|
|
.expect("the decommission reservation should fit");
|
|
|
|
assert!(
|
|
matches!(
|
|
ensure_external_decommission_target_admission(&meta, 1, "ordinary_put"),
|
|
Err(Error::SlowDown)
|
|
),
|
|
"an ordinary write must not consume a target reservation"
|
|
);
|
|
let rebalance_opts = ObjectOptions {
|
|
data_movement: true,
|
|
src_pool_idx: 0,
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
DecommissionCapacityOwner::from_options(&rebalance_opts).is_none(),
|
|
"rebalance data movement must remain an external capacity consumer"
|
|
);
|
|
let reservation = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("the active reservation should remain available");
|
|
let expected_owner = DecommissionCapacityOwner {
|
|
source_pool_index: 0,
|
|
operation_id: reservation.operation_id,
|
|
generation: reservation.generation,
|
|
owner_nonce: reservation.owner_nonce,
|
|
mutation_id: None,
|
|
};
|
|
ensure_decommission_target_owner_admission(&meta, expected_owner, 1, 10, now)
|
|
.expect("the reservation owner should consume its own allocation under the shared boundary");
|
|
let mut decommission_opts = rebalance_opts;
|
|
expected_owner.apply_to(&mut decommission_opts);
|
|
assert_eq!(DecommissionCapacityOwner::from_options(&decommission_opts), Some(expected_owner));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_physical_capacity_uses_per_set_bottleneck_and_widest_usage() {
|
|
let disks = [10_u64, 20, 30, 40]
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(disk_index, available_space)| rustfs_madmin::Disk {
|
|
endpoint: format!("http://node-{disk_index}"),
|
|
drive_path: format!("/disk-{disk_index}"),
|
|
state: "ok".to_string(),
|
|
total_space: 100,
|
|
used_space: 100 - available_space,
|
|
available_space,
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
disk_index: disk_index as i32,
|
|
..Default::default()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let capacity = decommission_physical_pool_capacity(
|
|
&disks,
|
|
0,
|
|
DecommissionErasureLayout { data: 2, parity: 2 },
|
|
PoolSpaceInfo {
|
|
free: 0,
|
|
total: 0,
|
|
used: 0,
|
|
},
|
|
);
|
|
|
|
assert_eq!(capacity, (400, 40, 360));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_terminal_transitions_release_capacity_reservations() {
|
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::hours(1);
|
|
let reservation = build_decommission_capacity_reservation(
|
|
DecommissionPoolCapacityInfo::for_test(0, DecommissionErasureLayout { data: 1, parity: 0 }, 0, 50, 50),
|
|
DecommissionErasureLayout { data: 1, parity: 0 },
|
|
uuid::Uuid::new_v4(),
|
|
7,
|
|
now,
|
|
)
|
|
.expect("test reservation should be valid");
|
|
let active = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(now),
|
|
capacity_reservation: Some(reservation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
|
|
for (reason, transition) in [
|
|
(
|
|
DECOMMISSION_CAPACITY_RELEASE_CANCELED,
|
|
PoolMeta::decommission_cancel as fn(&mut PoolMeta, usize) -> bool,
|
|
),
|
|
(DECOMMISSION_CAPACITY_RELEASE_FAILED, PoolMeta::decommission_failed),
|
|
(DECOMMISSION_CAPACITY_RELEASE_COMPLETED, PoolMeta::decommission_complete),
|
|
] {
|
|
let mut meta = active.clone();
|
|
assert!(transition(&mut meta, 0));
|
|
let released = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("terminal metadata should retain reservation observability");
|
|
assert!(released.released_at.is_some());
|
|
assert_eq!(released.release_reason.as_deref(), Some(reason));
|
|
assert_eq!(
|
|
released.operation_id,
|
|
active.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.unwrap()
|
|
.capacity_reservation
|
|
.as_ref()
|
|
.unwrap()
|
|
.operation_id
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_capacity_target_round_trip_preserves_temporary_mutation_without_pending_intent() {
|
|
let mutation_id = uuid::Uuid::new_v4();
|
|
let target = DecommissionCapacityTarget {
|
|
pool_index: 1,
|
|
layout: DecommissionErasureLayout { data: 2, parity: 2 },
|
|
physical_total_at_reservation: 200,
|
|
physical_free_at_reservation: 200,
|
|
reserved_physical_bytes: 200,
|
|
consumed_physical_bytes: 0,
|
|
observed_physical_bytes: 1,
|
|
inflight_physical_bytes: 1,
|
|
pending_physical_bytes: 0,
|
|
pending_mutation_id: None,
|
|
temporary_mutations: vec![DecommissionCapacityTemporaryMutation {
|
|
mutation_id,
|
|
physical_bytes: 1,
|
|
}],
|
|
};
|
|
let mut encoded = Vec::new();
|
|
target
|
|
.serialize(&mut Serializer::new(&mut encoded))
|
|
.expect("capacity target should serialize");
|
|
|
|
let restored: DecommissionCapacityTarget =
|
|
rmp_serde::from_slice(&encoded).expect("capacity target with a released pending intent should deserialize");
|
|
|
|
assert_eq!(restored, target);
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_cleanup_releases_only_its_exact_scoped_mutation_and_is_idempotent() {
|
|
let mutation_id = uuid::Uuid::new_v4();
|
|
let foreign_id = uuid::Uuid::new_v4();
|
|
let mut meta = decommission_test_cleanup_meta(
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION,
|
|
vec![
|
|
DecommissionCapacityTemporaryMutation {
|
|
mutation_id,
|
|
physical_bytes: 10,
|
|
},
|
|
DecommissionCapacityTemporaryMutation {
|
|
mutation_id: foreign_id,
|
|
physical_bytes: 20,
|
|
},
|
|
],
|
|
30,
|
|
None,
|
|
);
|
|
|
|
assert!(
|
|
release_decommission_target_inflight(
|
|
&mut meta,
|
|
0,
|
|
1,
|
|
0,
|
|
mutation_id,
|
|
DecommissionCapacityReleaseProof::confirmed_absence(true),
|
|
OffsetDateTime::UNIX_EPOCH + Duration::seconds(1),
|
|
)
|
|
.expect("confirmed absence should release the exact mutation")
|
|
);
|
|
let reservation = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("cleanup reservation should remain present");
|
|
assert_eq!(reservation.inflight_target_physical_bytes, 20);
|
|
assert_eq!(
|
|
reservation.targets[0].temporary_mutations,
|
|
vec![DecommissionCapacityTemporaryMutation {
|
|
mutation_id: foreign_id,
|
|
physical_bytes: 20,
|
|
}]
|
|
);
|
|
|
|
let before_replay = reservation.clone();
|
|
assert!(
|
|
!release_decommission_target_inflight(
|
|
&mut meta,
|
|
0,
|
|
1,
|
|
0,
|
|
mutation_id,
|
|
DecommissionCapacityReleaseProof::confirmed_absence(true),
|
|
OffsetDateTime::UNIX_EPOCH + Duration::seconds(2),
|
|
)
|
|
.expect("repeated confirmed absence should be a metadata no-op")
|
|
);
|
|
let after_replay = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("cleanup reservation should remain present");
|
|
assert_eq!(after_replay, &before_replay);
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_cleanup_uses_aggregate_fallback_only_for_legacy_unscoped_state() {
|
|
let mutation_id = uuid::Uuid::new_v4();
|
|
let mut meta = decommission_test_cleanup_meta(DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION, Vec::new(), 30, None);
|
|
|
|
assert!(
|
|
release_decommission_target_inflight(
|
|
&mut meta,
|
|
0,
|
|
1,
|
|
12,
|
|
mutation_id,
|
|
DecommissionCapacityReleaseProof::confirmed_absence(true),
|
|
OffsetDateTime::UNIX_EPOCH + Duration::seconds(1),
|
|
)
|
|
.expect("legacy unscoped cleanup should use its observed release delta")
|
|
);
|
|
let reservation = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("legacy cleanup reservation should remain present");
|
|
assert_eq!(reservation.inflight_target_physical_bytes, 18);
|
|
assert_eq!(reservation.targets[0].inflight_physical_bytes, 18);
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_cleanup_clears_only_a_matching_pending_identity_after_confirmed_absence() {
|
|
let mutation_id = uuid::Uuid::new_v4();
|
|
let mut meta = decommission_test_cleanup_meta(
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION,
|
|
Vec::new(),
|
|
0,
|
|
Some((mutation_id, 15)),
|
|
);
|
|
|
|
assert!(
|
|
release_decommission_target_inflight(
|
|
&mut meta,
|
|
0,
|
|
1,
|
|
7,
|
|
mutation_id,
|
|
DecommissionCapacityReleaseProof::confirmed_absence(true),
|
|
OffsetDateTime::UNIX_EPOCH + Duration::seconds(1),
|
|
)
|
|
.expect("a confirmed absent upload should clear its matching pending identity")
|
|
);
|
|
let reservation = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("cleanup reservation should remain present");
|
|
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
|
assert_eq!(reservation.targets[0].pending_mutation_id, None);
|
|
assert_eq!(reservation.targets[0].pending_physical_bytes, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_cleanup_preserves_pending_for_an_already_published_target() {
|
|
let mutation_id = uuid::Uuid::new_v4();
|
|
let mut meta = decommission_test_cleanup_meta(
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION,
|
|
vec![DecommissionCapacityTemporaryMutation {
|
|
mutation_id,
|
|
physical_bytes: 5,
|
|
}],
|
|
5,
|
|
Some((mutation_id, 15)),
|
|
);
|
|
|
|
assert!(
|
|
release_decommission_target_inflight(
|
|
&mut meta,
|
|
0,
|
|
1,
|
|
0,
|
|
mutation_id,
|
|
DecommissionCapacityReleaseProof::confirmed_absence(false),
|
|
OffsetDateTime::UNIX_EPOCH + Duration::seconds(1),
|
|
)
|
|
.expect("published-target cleanup should release only its temporary staging state")
|
|
);
|
|
let reservation = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("cleanup reservation should remain present");
|
|
assert_eq!(reservation.inflight_target_physical_bytes, 0);
|
|
assert_eq!(reservation.pending_target_physical_bytes, 15);
|
|
assert_eq!(reservation.targets[0].pending_mutation_id, Some(mutation_id));
|
|
assert_eq!(reservation.targets[0].pending_physical_bytes, 15);
|
|
assert!(reservation.targets[0].temporary_mutations.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn published_pending_ignores_a_delayed_release_observation() {
|
|
let mutation_id = uuid::Uuid::new_v4();
|
|
let mut meta = decommission_test_cleanup_meta(
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION,
|
|
Vec::new(),
|
|
0,
|
|
Some((mutation_id, 15)),
|
|
);
|
|
let before = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("published-target reservation should remain present")
|
|
.clone();
|
|
|
|
assert!(
|
|
!release_decommission_target_inflight(
|
|
&mut meta,
|
|
0,
|
|
1,
|
|
7,
|
|
mutation_id,
|
|
DecommissionCapacityReleaseProof::confirmed_absence(false),
|
|
OffsetDateTime::UNIX_EPOCH + Duration::seconds(1),
|
|
)
|
|
.expect("a delayed statfs release must not block published-target reconciliation")
|
|
);
|
|
let after = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("published-target reservation should remain present");
|
|
assert_eq!(after, &before);
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_cleanup_fails_closed_on_a_foreign_scoped_release_delta() {
|
|
let foreign_id = uuid::Uuid::new_v4();
|
|
let mut meta = decommission_test_cleanup_meta(
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION,
|
|
vec![DecommissionCapacityTemporaryMutation {
|
|
mutation_id: foreign_id,
|
|
physical_bytes: 20,
|
|
}],
|
|
20,
|
|
None,
|
|
);
|
|
let before = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("cleanup reservation should remain present")
|
|
.clone();
|
|
|
|
let err = release_decommission_target_inflight(
|
|
&mut meta,
|
|
0,
|
|
1,
|
|
5,
|
|
uuid::Uuid::new_v4(),
|
|
DecommissionCapacityReleaseProof::confirmed_absence(true),
|
|
OffsetDateTime::UNIX_EPOCH + Duration::seconds(1),
|
|
)
|
|
.expect_err("an observed release cannot be charged to a foreign scoped mutation");
|
|
assert!(err.to_string().contains("cannot be attributed"));
|
|
let after = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("cleanup reservation should remain present");
|
|
assert_eq!(after, &before);
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_cleanup_missing_identity_and_nonzero_delta_is_a_metadata_noop() {
|
|
let mut meta = decommission_test_cleanup_meta(DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION, Vec::new(), 0, None);
|
|
let before = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("cleanup reservation should remain present")
|
|
.clone();
|
|
|
|
assert!(
|
|
!release_decommission_target_inflight(
|
|
&mut meta,
|
|
0,
|
|
1,
|
|
7,
|
|
uuid::Uuid::new_v4(),
|
|
DecommissionCapacityReleaseProof::confirmed_absence(true),
|
|
OffsetDateTime::UNIX_EPOCH + Duration::seconds(1),
|
|
)
|
|
.expect("confirmed absence without tracked state should ignore a delayed capacity observation")
|
|
);
|
|
let after = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("cleanup reservation should remain present");
|
|
assert_eq!(after, &before);
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_cleanup_missing_identity_fails_closed_on_foreign_pending_state() {
|
|
let foreign_id = uuid::Uuid::new_v4();
|
|
let mut meta = decommission_test_cleanup_meta(
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION,
|
|
Vec::new(),
|
|
0,
|
|
Some((foreign_id, 11)),
|
|
);
|
|
let before = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("cleanup reservation should remain present")
|
|
.clone();
|
|
|
|
let err = release_decommission_target_inflight(
|
|
&mut meta,
|
|
0,
|
|
1,
|
|
7,
|
|
uuid::Uuid::new_v4(),
|
|
DecommissionCapacityReleaseProof::confirmed_absence(true),
|
|
OffsetDateTime::UNIX_EPOCH + Duration::seconds(1),
|
|
)
|
|
.expect_err("a delayed release observation must not clear a foreign pending mutation");
|
|
assert!(err.to_string().contains("cannot be attributed"));
|
|
let after = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("cleanup reservation should remain present");
|
|
assert_eq!(after, &before);
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v2_round_trip_preserves_each_supported_capacity_lock_model() {
|
|
for model_version in [
|
|
DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION,
|
|
DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION,
|
|
] {
|
|
let meta = decommission_test_cleanup_meta(model_version, Vec::new(), 0, None);
|
|
let encoded = meta
|
|
.encode_config_data_for_test()
|
|
.expect("supported capacity lock model should encode in pool metadata V2");
|
|
let mut restored = PoolMeta::default();
|
|
restored
|
|
.load_from_config_data(encoded)
|
|
.expect("supported capacity lock model should decode from pool metadata V2");
|
|
assert_eq!(
|
|
restored.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("round-tripped reservation should remain present")
|
|
.model_version,
|
|
model_version
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pool_meta_v2_decode_rejects_mixed_active_capacity_lock_models() {
|
|
let mut mixed = decommission_test_cleanup_meta(DECOMMISSION_CAPACITY_LEGACY_MODEL_VERSION, Vec::new(), 0, None);
|
|
let mut target_fence_source =
|
|
decommission_test_cleanup_meta(DECOMMISSION_CAPACITY_TARGET_FENCE_MODEL_VERSION, Vec::new(), 0, None)
|
|
.pools
|
|
.remove(0);
|
|
target_fence_source.id = 2;
|
|
target_fence_source.cmd_line = "pool-2".to_string();
|
|
target_fence_source
|
|
.decommission
|
|
.as_mut()
|
|
.and_then(|info| info.capacity_reservation.as_mut())
|
|
.expect("second active reservation should remain present")
|
|
.source_pool_index = 2;
|
|
mixed.pools.push(target_fence_source);
|
|
|
|
let encoded = mixed
|
|
.encode_config_data_for_test()
|
|
.expect("the decode test must be able to construct a mixed persisted payload");
|
|
let mut restored = PoolMeta::default();
|
|
let err = restored
|
|
.load_from_config_data(encoded)
|
|
.expect_err("mixed active lock models must fail closed while loading pool metadata");
|
|
assert!(err.to_string().contains("mixed lock models"));
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_capacity_reservation_recovers_expired_lease_after_restart_round_trip() {
|
|
let created_at = OffsetDateTime::UNIX_EPOCH + Duration::hours(1);
|
|
let recovered_at = created_at + DECOMMISSION_CAPACITY_RESERVATION_TTL + Duration::seconds(1);
|
|
let mut reservation = build_decommission_capacity_reservation(
|
|
DecommissionPoolCapacityInfo::for_test(0, DecommissionErasureLayout { data: 2, parity: 2 }, 0, 100, 100),
|
|
DecommissionErasureLayout { data: 2, parity: 2 },
|
|
uuid::Uuid::new_v4(),
|
|
41,
|
|
created_at,
|
|
)
|
|
.expect("test reservation should be valid");
|
|
reservation.targets.push(DecommissionCapacityTarget {
|
|
pool_index: 1,
|
|
layout: DecommissionErasureLayout { data: 2, parity: 2 },
|
|
physical_total_at_reservation: 200,
|
|
physical_free_at_reservation: 200,
|
|
reserved_physical_bytes: 200,
|
|
consumed_physical_bytes: 0,
|
|
observed_physical_bytes: 0,
|
|
inflight_physical_bytes: 0,
|
|
pending_physical_bytes: 0,
|
|
pending_mutation_id: None,
|
|
temporary_mutations: Vec::new(),
|
|
});
|
|
let operation_id = reservation.operation_id;
|
|
let owner_nonce = reservation.owner_nonce;
|
|
let mut meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(created_at),
|
|
capacity_reservation: Some(reservation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
|
|
let encoded = meta
|
|
.encode_config_data_for_test()
|
|
.expect("capacity reservation should persist in V2");
|
|
let mut restored = PoolMeta::default();
|
|
restored
|
|
.load_from_config_data(encoded)
|
|
.expect("capacity reservation should survive a restart round trip");
|
|
let info = restored.pools[0]
|
|
.decommission
|
|
.as_mut()
|
|
.expect("active decommission should restore");
|
|
let recovered = info.capacity_reservation.as_mut().expect("reservation should remain present");
|
|
assert!(renew_decommission_capacity_reservation(recovered, recovered_at, true));
|
|
assert_eq!(recovered.operation_id, operation_id);
|
|
assert_eq!(recovered.generation, 41);
|
|
assert_ne!(recovered.owner_nonce, owner_nonce);
|
|
assert_eq!(recovered.recovered_at, Some(recovered_at));
|
|
assert_eq!(recovered.expires_at, recovered_at + DECOMMISSION_CAPACITY_RESERVATION_TTL);
|
|
assert_eq!(next_decommission_capacity_generation(&restored).unwrap(), 42);
|
|
|
|
meta.decommission_failed(0);
|
|
let terminal = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("failed metadata should retain the released reservation");
|
|
assert!(!terminal.active());
|
|
}
|
|
|
|
#[test]
|
|
fn decommission_restart_reconstructs_a_missing_capacity_reservation_fail_closed() {
|
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::hours(2);
|
|
let mut meta = PoolMeta {
|
|
version: POOL_META_VERSION,
|
|
pools: vec![
|
|
decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(now - Duration::minutes(1)),
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(1, None),
|
|
],
|
|
..Default::default()
|
|
};
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let capacity_infos = vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 60, 60, 0),
|
|
];
|
|
|
|
let recovered_indices = recover_decommission_capacity_reservations(&mut meta, &capacity_infos, now, true)
|
|
.expect("restart recovery should rebuild the missing reservation while capacity still fits");
|
|
assert_eq!(recovered_indices, vec![0]);
|
|
let reservation = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.expect("restart recovery should persist a replacement reservation");
|
|
assert_eq!(reservation.peak_physical_bytes, 60);
|
|
assert_eq!(reservation.recovered_at, Some(now));
|
|
assert_eq!(reservation.generation, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_pool_states_rejects_blocked_pool() {
|
|
let meta = PoolMeta {
|
|
pools: vec![
|
|
PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
},
|
|
],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = ensure_decommission_start_pool_states(&meta, &[0]).expect_err("blocked pool should be rejected");
|
|
|
|
assert!(err.to_string().contains("target pool decommission is blocked"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_pool_states_allows_active_pool_with_remaining_active_pool() {
|
|
let meta = PoolMeta {
|
|
pools: vec![
|
|
PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
},
|
|
PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
},
|
|
],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(ensure_decommission_start_pool_states(&meta, &[0]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_start_pool_states_allows_retryable_pool_with_active_peer() {
|
|
let meta = PoolMeta {
|
|
pools: vec![
|
|
PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
unresolved_entries: vec![DecommissionUnresolvedEntry {
|
|
bucket: "bucket-a".to_string(),
|
|
object: "object-a".to_string(),
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
source_generation: OffsetDateTime::UNIX_EPOCH,
|
|
candidate_count: 1,
|
|
disk_error_count: 1,
|
|
observed_at: OffsetDateTime::UNIX_EPOCH,
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
},
|
|
PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
},
|
|
],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(ensure_decommission_start_pool_states(&meta, &[0]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_valid_decommission_pool_index_accepts_in_range_index() {
|
|
assert!(ensure_valid_decommission_pool_index(4, 3).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_valid_decommission_pool_index_rejects_out_of_range_index() {
|
|
let err = ensure_valid_decommission_pool_index(2, 2).expect_err("out-of-range index should fail");
|
|
assert!(err.to_string().contains("invalid decommission pool index 2 for 2 pools"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_valid_decommission_pool_index_rejects_when_pool_count_zero() {
|
|
let err = ensure_valid_decommission_pool_index(0, 0).expect_err("empty pool list should reject all indices");
|
|
assert!(err.to_string().contains("invalid decommission pool index 0 for 0 pools"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_decommission_terminal_state_completed_when_no_failures() {
|
|
assert_eq!(classify_decommission_terminal_state(false), DecommissionTerminalState::Completed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_decommission_terminal_state_failed_when_failures_present() {
|
|
assert_eq!(classify_decommission_terminal_state(true), DecommissionTerminalState::Failed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_preserve_decommission_canceled_state_when_meta_canceled() {
|
|
assert!(should_preserve_decommission_canceled_state(true, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_preserve_decommission_canceled_state_when_signal_canceled() {
|
|
assert!(!should_preserve_decommission_canceled_state(false, true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_preserve_decommission_canceled_state_when_not_canceled() {
|
|
assert!(!should_preserve_decommission_canceled_state(false, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_continue_decommission_queue_requires_clean_completion() {
|
|
let meta = PoolMeta {
|
|
pools: vec![
|
|
PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo::default()),
|
|
},
|
|
PoolStatus {
|
|
id: 2,
|
|
cmd_line: "pool-2".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
PoolStatus {
|
|
id: 3,
|
|
cmd_line: "pool-3".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
canceled: true,
|
|
..Default::default()
|
|
}),
|
|
},
|
|
PoolStatus {
|
|
id: 4,
|
|
cmd_line: "pool-4".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
},
|
|
],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(should_continue_decommission_queue(&meta, 0));
|
|
assert!(!should_continue_decommission_queue(&meta, 1));
|
|
assert!(!should_continue_decommission_queue(&meta, 2));
|
|
assert!(!should_continue_decommission_queue(&meta, 3));
|
|
assert!(!should_continue_decommission_queue(&meta, 4));
|
|
assert!(!should_continue_decommission_queue(&meta, 5));
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_cancel_signal_result_returns_err_when_canceled() {
|
|
let err = decommission_cancel_signal_result(true).expect_err("canceled signal should return operation-canceled");
|
|
assert!(matches!(err, Error::OperationCanceled));
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_cancel_signal_result_returns_ok_when_not_canceled() {
|
|
assert!(decommission_cancel_signal_result(false).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_decommission_cancel_requested_accepts_signal_or_metadata() {
|
|
let pool = PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
canceled: true,
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
assert!(is_decommission_cancel_requested(false, Some(&pool)));
|
|
assert!(is_decommission_cancel_requested(true, None));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_decommission_cancel_requested_rejects_active_without_signal() {
|
|
let pool = PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo::default()),
|
|
};
|
|
|
|
assert!(!is_decommission_cancel_requested(false, Some(&pool)));
|
|
assert!(!is_decommission_cancel_requested(false, None));
|
|
}
|
|
|
|
#[test]
|
|
fn test_skip_canceled_decommission_routine_only_for_terminal_canceled_state() {
|
|
let canceled = PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
canceled: true,
|
|
..Default::default()
|
|
}),
|
|
};
|
|
let active = PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo::default()),
|
|
};
|
|
|
|
assert!(should_skip_canceled_decommission_routine(true, Some(&canceled)));
|
|
assert!(!should_skip_canceled_decommission_routine(false, Some(&canceled)));
|
|
assert!(!should_skip_canceled_decommission_routine(true, Some(&active)));
|
|
assert!(!should_skip_canceled_decommission_routine(true, None));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_cancel_allowed_rejects_missing_pool() {
|
|
let err = ensure_decommission_cancel_allowed(false, false, false).expect_err("missing pool should be invalid");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to cancel decommission: target pool was not found")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_reject_decommission_cancel_as_terminal_true_when_completed() {
|
|
assert!(should_reject_decommission_cancel_as_terminal(true, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_reject_decommission_cancel_as_terminal_true_when_failed() {
|
|
assert!(should_reject_decommission_cancel_as_terminal(false, true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_reject_decommission_cancel_as_terminal_false_when_active_or_canceled() {
|
|
assert!(!should_reject_decommission_cancel_as_terminal(false, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_retry_decommission_cancel_reload_when_changed_or_already_canceled() {
|
|
assert!(should_retry_decommission_cancel_reload(true, false));
|
|
assert!(should_retry_decommission_cancel_reload(false, true));
|
|
assert!(!should_retry_decommission_cancel_reload(false, false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_cancel_allowed_rejects_not_started() {
|
|
let err =
|
|
ensure_decommission_cancel_allowed(true, false, false).expect_err("not-started decommission should be rejected");
|
|
assert!(matches!(err, Error::DecommissionNotStarted));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_cancel_allowed_rejects_terminal() {
|
|
let err = ensure_decommission_cancel_allowed(true, true, true).expect_err("terminal decommission should be rejected");
|
|
assert!(matches!(err, Error::DecommissionNotStarted));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_cancel_allowed_allows_active() {
|
|
assert!(ensure_decommission_cancel_allowed(true, true, false).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_clear_allowed_allows_failed_or_canceled() {
|
|
assert!(ensure_decommission_clear_allowed(true, true, false, true, false, 0).is_ok());
|
|
assert!(ensure_decommission_clear_allowed(true, true, false, false, true, 0).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_clear_allowed_rejects_active_or_completed() {
|
|
let active = ensure_decommission_clear_allowed(true, true, false, false, false, 0)
|
|
.expect_err("active decommission should not be clearable");
|
|
assert!(matches!(active, Error::DecommissionAlreadyRunning));
|
|
|
|
let complete = ensure_decommission_clear_allowed(true, true, true, false, false, 0)
|
|
.expect_err("completed decommission should not be clearable");
|
|
assert!(matches!(complete, Error::DecommissionNotStarted));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_clear_allowed_rejects_unresolved_entries() {
|
|
let err = ensure_decommission_clear_allowed(true, true, false, true, false, 1)
|
|
.expect_err("unresolved entries must survive until a retry reconciles them");
|
|
|
|
assert!(err.to_string().contains("must be reconciled by retrying decommission"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_clear_decommission_restores_failed_or_canceled_pool() {
|
|
for decommission in [
|
|
PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
},
|
|
PoolDecommissionInfo {
|
|
canceled: true,
|
|
..Default::default()
|
|
},
|
|
] {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(decommission),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(meta.is_suspended(0));
|
|
assert!(meta.clear_decommission(0).expect("terminal decommission should clear"));
|
|
assert!(
|
|
meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.is_some_and(|info| !info.has_decommission_state())
|
|
);
|
|
assert!(!meta.is_suspended(0));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_clear_decommission_preserves_unresolved_entries_for_retry() {
|
|
let generation = OffsetDateTime::UNIX_EPOCH;
|
|
let unresolved_entry = DecommissionUnresolvedEntry {
|
|
bucket: "bucket-a".to_string(),
|
|
object: "object-a".to_string(),
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
source_generation: generation,
|
|
candidate_count: 1,
|
|
disk_error_count: 1,
|
|
observed_at: generation,
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
};
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: generation,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
unresolved_entries: vec![unresolved_entry.clone()],
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = meta
|
|
.clear_decommission(0)
|
|
.expect_err("clear must not discard the unresolved-entry recovery ledger");
|
|
|
|
assert!(err.to_string().contains("must be reconciled by retrying decommission"));
|
|
assert_eq!(
|
|
meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("failed state should remain retryable")
|
|
.unresolved_entries,
|
|
vec![unresolved_entry]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_clear_decommission_rejects_active_or_completed_pool() {
|
|
for decommission in [
|
|
PoolDecommissionInfo::default(),
|
|
PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
},
|
|
] {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(decommission),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(meta.clear_decommission(0).is_err());
|
|
assert!(meta.pools[0].decommission.is_some());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_contextualized_decommission_terminal_operation_supported_rejects_single_pool() {
|
|
let err = ensure_decommission_terminal_operation_supported(true, "complete decommission")
|
|
.expect_err("single-pool decommission terminal operations should be rejected");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to complete decommission: single pool deployments do not support decommission")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_contextualized_decommission_terminal_operation_supported_allows_multi_pool() {
|
|
assert!(ensure_decommission_terminal_operation_supported(false, "mark decommission failed").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_contextualized_decommission_start_request_rejects_empty_indices() {
|
|
let err = validate_start_decommission_request(&[], false).expect_err("empty decommission target list should be rejected");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to start decommission: no target pools were provided")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_contextualized_decommission_start_request_rejects_single_pool() {
|
|
let err = validate_start_decommission_request(&[0], true)
|
|
.expect_err("single-pool deployments should reject decommission start");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to start decommission: single pool deployments do not support decommission")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_contextualized_decommission_start_request_allows_multiple_target_pools() {
|
|
assert!(validate_start_decommission_request(&[0, 1], false).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_contextualized_decommission_start_request_allows_one_target_pool() {
|
|
assert!(validate_start_decommission_request(&[0], false).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_retry_preserves_and_rebinds_unresolved_entries() {
|
|
let previous_generation = OffsetDateTime::UNIX_EPOCH;
|
|
let unresolved_entry = DecommissionUnresolvedEntry {
|
|
bucket: "bucket-a".to_string(),
|
|
object: "object-a".to_string(),
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
source_generation: previous_generation,
|
|
candidate_count: 1,
|
|
disk_error_count: 1,
|
|
observed_at: previous_generation,
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
};
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: previous_generation,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
decommissioned_buckets: vec!["bucket-a".to_string()],
|
|
items_decommissioned: 7,
|
|
bytes_done: 1024,
|
|
unresolved_entries: vec![unresolved_entry],
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
meta.decommission(
|
|
0,
|
|
PoolSpaceInfo {
|
|
total: 200,
|
|
free: 50,
|
|
used: 150,
|
|
},
|
|
)
|
|
.expect("failed decommission with unresolved entries should retry atomically");
|
|
|
|
let info = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("retried decommission metadata should exist");
|
|
let next_generation = info.start_time.expect("retry should assign a new generation");
|
|
assert!(next_generation > previous_generation);
|
|
assert_eq!(info.decommissioned_buckets, vec!["bucket-a".to_string()]);
|
|
assert_eq!(info.items_decommissioned, 7);
|
|
assert_eq!(info.bytes_done, 1024);
|
|
assert_eq!(info.unresolved_entries.len(), 1);
|
|
assert_eq!(info.unresolved_entries[0].source_generation, next_generation);
|
|
}
|
|
|
|
#[test]
|
|
fn test_queued_decommission_retry_rebinds_unresolved_entries_when_promoted() {
|
|
let previous_generation = OffsetDateTime::UNIX_EPOCH;
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: previous_generation,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
canceled: true,
|
|
unresolved_entries: vec![DecommissionUnresolvedEntry {
|
|
bucket: "bucket-a".to_string(),
|
|
object: "object-a".to_string(),
|
|
pool_index: 0,
|
|
set_index: 0,
|
|
source_generation: previous_generation,
|
|
candidate_count: 1,
|
|
disk_error_count: 1,
|
|
observed_at: previous_generation,
|
|
reason: "metadata_resolution_failed".to_string(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
meta.queue_decommission(
|
|
0,
|
|
PoolSpaceInfo {
|
|
total: 100,
|
|
free: 25,
|
|
used: 75,
|
|
},
|
|
)
|
|
.expect("canceled decommission with unresolved entries should queue an atomic retry");
|
|
|
|
assert!(meta.is_suspended(0));
|
|
assert!(meta.promote_queued_decommission(0));
|
|
let promoted = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("promoted decommission metadata should exist");
|
|
let generation = promoted.start_time.expect("promotion should assign a generation");
|
|
assert_eq!(promoted.unresolved_entries.len(), 1);
|
|
assert_eq!(promoted.unresolved_entries[0].source_generation, generation);
|
|
}
|
|
|
|
#[test]
|
|
fn test_queued_decommission_promotion_advances_generation_after_clock_rollback() {
|
|
let queued_at = OffsetDateTime::from_unix_timestamp(1_260).expect("fixed timestamp should be valid");
|
|
let earlier_tick = queued_at - Duration::nanoseconds(10);
|
|
let rebalance_floor = queued_at + Duration::nanoseconds(5);
|
|
let rebalance = RebalanceMeta {
|
|
stopped_at: Some(rebalance_floor),
|
|
id: "completed-rebalance".to_string(),
|
|
..Default::default()
|
|
};
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: queued_at,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
queued: true,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(meta.promote_queued_decommission_at_for_test(0, earlier_tick, Some(&rebalance)));
|
|
let expected_generation = rebalance_floor + Duration::nanoseconds(1);
|
|
let promoted = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("promoted decommission metadata should exist");
|
|
assert_eq!(meta.pools[0].last_update, expected_generation);
|
|
assert_eq!(promoted.start_time, Some(expected_generation));
|
|
assert!(!promoted.queued);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_queued_decommission_is_suspended_to_preserve_reserved_capacity() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
meta.queue_decommission(
|
|
0,
|
|
PoolSpaceInfo {
|
|
total: 100,
|
|
free: 10,
|
|
used: 90,
|
|
},
|
|
)
|
|
.expect("queued decommission should be stored");
|
|
|
|
assert!(meta.is_suspended(0));
|
|
assert!(meta.promote_queued_decommission(0));
|
|
assert!(meta.is_suspended(0));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_promoted_queued_decommission_can_be_canceled() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
meta.queue_decommission(
|
|
0,
|
|
PoolSpaceInfo {
|
|
total: 100,
|
|
free: 10,
|
|
used: 90,
|
|
},
|
|
)
|
|
.expect("queued decommission should be stored");
|
|
|
|
assert!(pool_meta_has_active_decommission(&meta));
|
|
assert!(meta.promote_queued_decommission(0));
|
|
assert!(meta.decommission_cancel(0));
|
|
|
|
let info = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("canceled decommission state should be kept for clear");
|
|
assert!(info.canceled);
|
|
assert!(!info.queued);
|
|
assert!(!info.failed);
|
|
assert!(!info.complete);
|
|
assert!(!pool_meta_has_active_decommission(&meta));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_failed_decommission_requires_clear_before_restart() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
decommissioned_buckets: vec!["bucket-done".to_string()],
|
|
queued_buckets: vec!["bucket-pending".to_string()],
|
|
bucket: "bucket-pending".to_string(),
|
|
prefix: "prefix".to_string(),
|
|
object: "object.txt".to_string(),
|
|
items_decommissioned: 7,
|
|
items_decommission_failed: 3,
|
|
bytes_done: 1024,
|
|
bytes_failed: 256,
|
|
progress_save_item_baseline: 10,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = meta
|
|
.decommission(
|
|
0,
|
|
PoolSpaceInfo {
|
|
total: 200,
|
|
free: 50,
|
|
used: 150,
|
|
},
|
|
)
|
|
.expect_err("failed decommission should be blocked until cleared");
|
|
assert!(err.to_string().contains("target pool decommission is blocked"));
|
|
let blocked = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("blocked metadata should remain until clear");
|
|
assert!(blocked.failed);
|
|
assert_eq!(blocked.decommissioned_buckets, vec!["bucket-done".to_string()]);
|
|
assert_eq!(blocked.items_decommissioned, 7);
|
|
assert_eq!(blocked.bytes_done, 1024);
|
|
|
|
assert!(meta.clear_decommission(0).expect("failed decommission should clear"));
|
|
assert!(
|
|
meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.is_some_and(|info| !info.has_decommission_state())
|
|
);
|
|
|
|
meta.decommission(
|
|
0,
|
|
PoolSpaceInfo {
|
|
total: 200,
|
|
free: 50,
|
|
used: 150,
|
|
},
|
|
)
|
|
.expect("cleared decommission should be restartable");
|
|
meta.queue_buckets(
|
|
0,
|
|
vec![
|
|
DecomBucketInfo {
|
|
name: "bucket-done".to_string(),
|
|
prefix: String::new(),
|
|
},
|
|
DecomBucketInfo {
|
|
name: "bucket-pending".to_string(),
|
|
prefix: String::new(),
|
|
},
|
|
],
|
|
);
|
|
|
|
let info = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("decommission info should be rebuilt");
|
|
assert!(!info.failed);
|
|
assert!(!info.canceled);
|
|
assert!(!info.complete);
|
|
assert!(info.decommissioned_buckets.is_empty());
|
|
assert_eq!(info.queued_buckets, vec!["bucket-done".to_string(), "bucket-pending".to_string()]);
|
|
assert_eq!(info.items_decommissioned, 0);
|
|
assert_eq!(info.items_decommission_failed, 0);
|
|
assert_eq!(info.bytes_done, 0);
|
|
assert_eq!(info.bytes_failed, 0);
|
|
assert_eq!(info.items_since_last_progress_save(), 0);
|
|
assert_eq!(info.start_size, 50);
|
|
assert_eq!(info.total_size, 200);
|
|
assert_eq!(info.current_size, 50);
|
|
assert_eq!(info.bucket, "bucket-pending");
|
|
assert!(info.prefix.is_empty());
|
|
assert!(info.object.is_empty());
|
|
assert!(info.start_time.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_pool_meta_canceled_queued_decommission_requires_clear_before_restart() {
|
|
let mut meta = PoolMeta {
|
|
pools: vec![PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
canceled: true,
|
|
decommissioned_buckets: vec!["bucket-done".to_string()],
|
|
items_decommissioned: 5,
|
|
bytes_done: 512,
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = meta
|
|
.queue_decommission(
|
|
0,
|
|
PoolSpaceInfo {
|
|
total: 100,
|
|
free: 25,
|
|
used: 75,
|
|
},
|
|
)
|
|
.expect_err("canceled queued decommission should be blocked until cleared");
|
|
assert!(err.to_string().contains("target pool decommission is blocked"));
|
|
let blocked = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("blocked metadata should remain until clear");
|
|
assert!(blocked.canceled);
|
|
assert_eq!(blocked.decommissioned_buckets, vec!["bucket-done".to_string()]);
|
|
assert_eq!(blocked.items_decommissioned, 5);
|
|
assert_eq!(blocked.bytes_done, 512);
|
|
|
|
assert!(meta.clear_decommission(0).expect("canceled decommission should clear"));
|
|
assert!(
|
|
meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.is_some_and(|info| !info.has_decommission_state())
|
|
);
|
|
|
|
meta.queue_decommission(
|
|
0,
|
|
PoolSpaceInfo {
|
|
total: 100,
|
|
free: 25,
|
|
used: 75,
|
|
},
|
|
)
|
|
.expect("cleared queued decommission should be restartable");
|
|
|
|
let info = meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("decommission info should be rebuilt");
|
|
assert!(info.queued);
|
|
assert!(info.start_time.is_none());
|
|
assert!(info.decommissioned_buckets.is_empty());
|
|
assert_eq!(info.items_decommissioned, 0);
|
|
assert_eq!(info.bytes_done, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_contextualized_decommission_listing_disks_available_rejects_empty_set() {
|
|
let err = ensure_decommission_listing_disks_available(false, "bucket-a")
|
|
.expect_err("missing online disks should be reported with bucket context");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to list objects to decommission for bucket bucket-a: no disks available")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_contextualized_decommission_listing_disks_available_allows_online_disks() {
|
|
assert!(ensure_decommission_listing_disks_available(true, "bucket-a").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_decommission_store_returns_value_when_present() {
|
|
let store = require_decommission_store(Some(7_u8), "start decommission").expect("present store should be returned");
|
|
assert_eq!(store, 7);
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_decommission_store_returns_error_when_missing() {
|
|
let err = require_decommission_store::<u8>(None, "start decommission").expect_err("missing store should return error");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to start decommission: store not initialized")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bind_decommission_cancelers_binds_existing_slots_only() {
|
|
let parent = CancellationToken::new();
|
|
let mut cancelers = vec![None, None];
|
|
|
|
let bound = bind_decommission_cancelers(&[0, 3, 1], &parent, cancelers.as_mut_slice());
|
|
|
|
assert_eq!(bound.len(), 2);
|
|
assert_eq!(bound[0].0, 0);
|
|
assert_eq!(bound[1].0, 1);
|
|
assert!(cancelers[0].is_some());
|
|
assert!(cancelers[1].is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_bind_decommission_cancelers_child_tokens_follow_parent_cancel() {
|
|
let parent = CancellationToken::new();
|
|
let mut cancelers = vec![None];
|
|
|
|
let bound = bind_decommission_cancelers(&[0], &parent, cancelers.as_mut_slice());
|
|
assert_eq!(bound.len(), 1);
|
|
assert!(!bound[0].1.is_cancelled());
|
|
|
|
parent.cancel();
|
|
assert!(bound[0].1.is_cancelled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_bind_decommission_cancelers_replaces_existing_slot() {
|
|
let parent = CancellationToken::new();
|
|
let existing = DecommissionCanceler::new(CancellationToken::new());
|
|
let mut cancelers = vec![Some(existing.clone())];
|
|
|
|
let bound = bind_decommission_cancelers(&[0], &parent, cancelers.as_mut_slice());
|
|
|
|
assert_eq!(bound.len(), 1);
|
|
assert_eq!(bound[0].0, 0);
|
|
assert!(existing.is_cancelled());
|
|
let replacement = cancelers[0].as_ref().expect("replacement token should be stored");
|
|
assert!(!replacement.is_cancelled());
|
|
parent.cancel();
|
|
assert!(replacement.is_cancelled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_bind_missing_decommission_cancelers_stops_at_existing_slot() {
|
|
let parent = CancellationToken::new();
|
|
let existing = DecommissionCanceler::new(CancellationToken::new());
|
|
let mut cancelers = vec![None, Some(existing.clone()), None];
|
|
|
|
let bound = bind_missing_decommission_cancelers(&[0, 1, 2], &parent, cancelers.as_mut_slice());
|
|
|
|
assert_eq!(bound.len(), 1);
|
|
assert_eq!(bound[0].0, 0);
|
|
assert!(cancelers[0].is_some());
|
|
assert!(cancelers[1].is_some());
|
|
assert!(cancelers[2].is_none());
|
|
assert!(!existing.is_cancelled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_serialized_decommission_double_start_preserves_first_operation() {
|
|
let mut pool_meta = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
|
|
..Default::default()
|
|
};
|
|
let first_parent = CancellationToken::new();
|
|
let second_parent = CancellationToken::new();
|
|
let mut cancelers = vec![None, None];
|
|
|
|
let first = reserve_decommission_start_cancelers(&pool_meta, &[0], &[0], &first_parent, cancelers.as_mut_slice())
|
|
.expect("first start should reserve its worker");
|
|
pool_meta
|
|
.decommission(
|
|
0,
|
|
PoolSpaceInfo {
|
|
total: 100,
|
|
free: 40,
|
|
used: 60,
|
|
},
|
|
)
|
|
.expect("first start should install active metadata");
|
|
|
|
let second = reserve_decommission_start_cancelers(&pool_meta, &[0], &[0], &second_parent, cancelers.as_mut_slice());
|
|
|
|
assert!(matches!(second, Err(Error::DecommissionAlreadyRunning)));
|
|
let current = cancelers[0].as_ref().expect("first operation should retain the slot");
|
|
assert!(current.owns_same_operation(first[0].1.canceler()));
|
|
assert!(current.is_active());
|
|
assert!(!first_parent.is_cancelled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_local_decommission_queue_prefix_stops_at_remote_leader() {
|
|
let endpoints = EndpointServerPools::from(vec![
|
|
decommission_test_pool_endpoint(0, true),
|
|
decommission_test_pool_endpoint(1, true),
|
|
decommission_test_pool_endpoint(2, false),
|
|
decommission_test_pool_endpoint(3, true),
|
|
]);
|
|
|
|
let local = local_decommission_queue_prefix(&endpoints, &[0, 1, 2, 3]).expect("prefix should resolve");
|
|
|
|
assert_eq!(local, vec![0, 1]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_local_decommission_queue_prefix_empty_when_first_leader_remote() {
|
|
let endpoints = EndpointServerPools::from(vec![
|
|
decommission_test_pool_endpoint(0, false),
|
|
decommission_test_pool_endpoint(1, true),
|
|
]);
|
|
|
|
let local = local_decommission_queue_prefix(&endpoints, &[0, 1]).expect("prefix should resolve");
|
|
|
|
assert!(local.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_start_local_leader_allows_remote_queued_pool() {
|
|
let endpoints = EndpointServerPools::from(vec![
|
|
decommission_test_pool_endpoint(0, true),
|
|
decommission_test_pool_endpoint(1, false),
|
|
]);
|
|
|
|
assert!(ensure_decommission_start_local_leader(&endpoints, &[0, 1]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_start_local_leader_rejects_remote_active_pool() {
|
|
let endpoints = EndpointServerPools::from(vec![decommission_test_pool_endpoint(0, false)]);
|
|
|
|
let err = ensure_decommission_start_local_leader(&endpoints, &[0]).expect_err("remote active pool should be rejected");
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("decommission for pool 0 must run on the pool first endpoint")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_missing_decommission_worker_prefix_stops_at_active_worker() {
|
|
let cancelers = vec![None, Some(DecommissionCanceler::new(CancellationToken::new())), None];
|
|
|
|
let missing = missing_decommission_worker_prefix(&[0, 1, 2], cancelers.as_slice());
|
|
|
|
assert_eq!(missing, vec![0]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resumable_decommission_queue_indices_skip_terminal_predecessors() {
|
|
let meta = PoolMeta {
|
|
pools: vec![
|
|
decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(
|
|
1,
|
|
Some(PoolDecommissionInfo {
|
|
canceled: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(
|
|
2,
|
|
Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(
|
|
3,
|
|
Some(PoolDecommissionInfo {
|
|
queued: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(4, None),
|
|
],
|
|
..Default::default()
|
|
};
|
|
|
|
assert_eq!(resumable_decommission_queue_indices(&meta), vec![3]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resumable_decommission_queue_indices_preserve_active_predecessor_order() {
|
|
let meta = PoolMeta {
|
|
pools: vec![
|
|
decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
complete: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(
|
|
1,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(
|
|
2,
|
|
Some(PoolDecommissionInfo {
|
|
queued: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
],
|
|
..Default::default()
|
|
};
|
|
|
|
assert_eq!(resumable_decommission_queue_indices(&meta), vec![1, 2]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_runtime_recovery_reserves_the_startup_resumable_queue() {
|
|
let meta = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![
|
|
decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(
|
|
1,
|
|
Some(PoolDecommissionInfo {
|
|
canceled: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(
|
|
2,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(
|
|
3,
|
|
Some(PoolDecommissionInfo {
|
|
queued: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
],
|
|
..Default::default()
|
|
};
|
|
let startup_ids = meta
|
|
.return_resumable_pools()
|
|
.into_iter()
|
|
.map(|pool| pool.id)
|
|
.collect::<Vec<_>>();
|
|
let store = decommission_worker_test_store(meta, vec![None, None, None, None]);
|
|
let endpoints = EndpointServerPools::from(vec![
|
|
decommission_test_pool_endpoint(0, true),
|
|
decommission_test_pool_endpoint(1, true),
|
|
decommission_test_pool_endpoint(2, true),
|
|
decommission_test_pool_endpoint(3, true),
|
|
]);
|
|
let reserved = store
|
|
.reserve_missing_local_decommission_routines(&CancellationToken::new(), &endpoints)
|
|
.await
|
|
.expect("runtime recovery reservation should succeed");
|
|
let runtime_indices = reserved.iter().map(|(idx, _)| *idx).collect::<Vec<_>>();
|
|
|
|
assert_eq!(runtime_indices, vec![2, 3]);
|
|
assert_eq!(startup_ids, vec![2, 3]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_runtime_recovery_does_not_reserve_behind_active_predecessor() {
|
|
let meta = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![
|
|
decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
failed: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(
|
|
1,
|
|
Some(PoolDecommissionInfo {
|
|
canceled: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(
|
|
2,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
}),
|
|
),
|
|
decommission_test_pool_status(
|
|
3,
|
|
Some(PoolDecommissionInfo {
|
|
queued: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
],
|
|
..Default::default()
|
|
};
|
|
let active = DecommissionCanceler::new(CancellationToken::new());
|
|
let store = decommission_worker_test_store(meta, vec![None, None, Some(active.clone()), None]);
|
|
let endpoints = EndpointServerPools::from(vec![
|
|
decommission_test_pool_endpoint(0, true),
|
|
decommission_test_pool_endpoint(1, true),
|
|
decommission_test_pool_endpoint(2, true),
|
|
decommission_test_pool_endpoint(3, true),
|
|
]);
|
|
|
|
let reserved = store
|
|
.reserve_missing_local_decommission_routines(&CancellationToken::new(), &endpoints)
|
|
.await
|
|
.expect("runtime recovery reservation should succeed");
|
|
|
|
assert!(reserved.is_empty());
|
|
assert!(active.is_active());
|
|
}
|
|
|
|
#[test]
|
|
fn test_take_decommission_canceler_takes_and_clears_slot() {
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let mut cancelers = vec![Some(canceler)];
|
|
|
|
let taken = take_decommission_canceler(cancelers.as_mut_slice(), 0);
|
|
assert!(taken.is_some());
|
|
assert!(cancelers[0].is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_take_decommission_canceler_returns_none_for_missing_slot() {
|
|
let mut cancelers: Vec<Option<DecommissionCanceler>> = Vec::new();
|
|
assert!(take_decommission_canceler(cancelers.as_mut_slice(), 0).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_has_active_decommission_canceler_true_when_any_slot_present() {
|
|
let cancelers = vec![None, Some(DecommissionCanceler::new(CancellationToken::new()))];
|
|
assert!(has_active_decommission_canceler(cancelers.as_slice()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_has_active_decommission_canceler_false_when_all_empty() {
|
|
let cancelers = vec![None, None];
|
|
assert!(!has_active_decommission_canceler(cancelers.as_slice()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cancel_decommission_canceler_cancels_when_present() {
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let canceled = cancel_decommission_canceler(Some(canceler.clone()));
|
|
|
|
assert!(canceled);
|
|
assert!(canceler.is_cancelled());
|
|
assert!(!canceler.is_active());
|
|
}
|
|
|
|
#[test]
|
|
fn test_cancel_decommission_canceler_returns_false_when_missing() {
|
|
assert!(!cancel_decommission_canceler(None));
|
|
}
|
|
|
|
#[test]
|
|
fn test_take_and_cancel_decommission_canceler_clears_slot() {
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let mut cancelers = vec![Some(canceler.clone())];
|
|
|
|
assert!(take_and_cancel_decommission_canceler(cancelers.as_mut_slice(), 0));
|
|
assert!(cancelers[0].is_none());
|
|
assert!(canceler.is_cancelled());
|
|
assert!(!canceler.is_active());
|
|
}
|
|
|
|
#[test]
|
|
fn test_take_and_cancel_decommission_canceler_missing_slot_is_false() {
|
|
let mut cancelers = vec![None];
|
|
|
|
assert!(!take_and_cancel_decommission_canceler(cancelers.as_mut_slice(), 0));
|
|
assert!(cancelers[0].is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_guarded_decommission_future_releases_without_first_poll() {
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let cancelers = vec![Some(canceler.clone())];
|
|
let guards = guard_decommission_cancelers(vec![(0, canceler.clone())]);
|
|
let unpolled = async move {
|
|
let _guards = guards;
|
|
std::future::pending::<()>().await;
|
|
};
|
|
|
|
drop(unpolled);
|
|
|
|
assert!(canceler.is_cancelled());
|
|
assert!(!has_active_decommission_canceler(cancelers.as_slice()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_partial_decommission_spawn_reservation_releases_bound_slot() {
|
|
let parent = CancellationToken::new();
|
|
let mut cancelers = vec![None];
|
|
let bound = bind_decommission_cancelers(&[0, 1], &parent, cancelers.as_mut_slice());
|
|
let guards = guard_decommission_cancelers(bound);
|
|
|
|
let result = super::ensure_decommission_routines_scheduled(guards.len(), 2);
|
|
drop(guards);
|
|
|
|
assert!(result.is_err());
|
|
assert!(!has_active_decommission_canceler(cancelers.as_slice()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_supervisor_observes_worker_abort() {
|
|
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
|
let worker = tokio::spawn(async move {
|
|
started_tx.send(()).expect("worker start should be observed");
|
|
std::future::pending::<()>().await;
|
|
#[allow(unreachable_code)]
|
|
Ok(())
|
|
});
|
|
|
|
started_rx.await.expect("worker start should be observed");
|
|
worker.abort();
|
|
let err = await_decommission_worker(3, worker)
|
|
.await
|
|
.expect_err("supervisor should observe aborted worker");
|
|
|
|
assert!(err.to_string().contains("decommission worker 3 task join error"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_supervisor_observes_worker_panic() {
|
|
let worker = tokio::spawn(async move {
|
|
panic!("injected decommission worker panic");
|
|
});
|
|
|
|
let err = await_decommission_worker(4, worker)
|
|
.await
|
|
.expect_err("supervisor should observe panicked worker");
|
|
|
|
assert!(err.to_string().contains("decommission worker 4 task join error"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_worker_metadata_missing_releases_owned_slot() {
|
|
let (_temp_dirs, store, _other_store) = crate::services::rebalance::test_two_pool_stores(None).await;
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
*store.pool_meta.write().await = PoolMeta::default();
|
|
store.decommission_cancelers.write().await[0] = Some(canceler.clone());
|
|
|
|
let err = store
|
|
.do_decommission_in_routine(canceler.clone(), 0, Arc::new(Semaphore::new(1)))
|
|
.await
|
|
.expect_err("missing worker metadata should fail the routine");
|
|
|
|
assert!(err.to_string().contains("target pool was not found"));
|
|
assert!(!canceler.is_active());
|
|
assert!(store.decommission_cancelers.read().await[0].is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_supervisor_failure_cancels_queued_successor() {
|
|
let first = DecommissionCanceler::new(CancellationToken::new());
|
|
let queued = DecommissionCanceler::new(CancellationToken::new());
|
|
let store = decommission_worker_test_store(PoolMeta::default(), vec![Some(first.clone()), Some(queued.clone())]);
|
|
let guards = guard_decommission_cancelers(vec![(0, first.clone()), (1, queued.clone())]);
|
|
|
|
spawn_decommission_index_cancelers(store.clone(), CancellationToken::new(), guards, Arc::new(Semaphore::new(1)))
|
|
.await
|
|
.expect("decommission supervisor should finish after queued cleanup");
|
|
|
|
assert!(!first.is_active());
|
|
assert!(!queued.is_active());
|
|
assert!(queued.is_cancelled());
|
|
assert!(store.decommission_cancelers.read().await.iter().all(Option::is_none));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_supervisor_releases_slot_without_terminal_retry_when_pool_meta_is_blocked() {
|
|
let generation = OffsetDateTime::UNIX_EPOCH;
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let pool_meta = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
|
|
store
|
|
.pool_meta_save_gate
|
|
.lock()
|
|
.await
|
|
.observe_replicas(super::PoolMetaReplicaState {
|
|
needs_repair: true,
|
|
repair_write_safe: false,
|
|
});
|
|
let guards = guard_decommission_cancelers(vec![(0, canceler.clone())]);
|
|
|
|
tokio::time::timeout(
|
|
StdDuration::from_secs(1),
|
|
spawn_decommission_index_cancelers(store.clone(), CancellationToken::new(), guards, Arc::new(Semaphore::new(1))),
|
|
)
|
|
.await
|
|
.expect("blocked supervisor must not enter terminal retry")
|
|
.expect("blocked supervisor task should not panic");
|
|
|
|
assert!(store.decommission_cancelers.read().await[0].is_none());
|
|
assert!(!canceler.is_active());
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("blocked decommission metadata should remain active");
|
|
assert!(!info.failed);
|
|
assert!(!info.canceled);
|
|
assert!(!info.complete);
|
|
assert_eq!(info.start_time, Some(generation));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_promotion_rechecks_sticky_gate_after_start_wait() {
|
|
let pool_meta = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
queued: true,
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![None]);
|
|
let start_guard = store.start_gate.lock().await;
|
|
let mut promotion = tokio::spawn({
|
|
let store = store.clone();
|
|
async move { store.promote_queued_decommission_for_test(0).await }
|
|
});
|
|
tokio::task::yield_now().await;
|
|
assert!(!promotion.is_finished(), "promotion should be waiting for the start gate");
|
|
|
|
store
|
|
.pool_meta_save_gate
|
|
.lock()
|
|
.await
|
|
.observe_replicas(super::PoolMetaReplicaState {
|
|
needs_repair: true,
|
|
repair_write_safe: false,
|
|
});
|
|
drop(start_guard);
|
|
|
|
let err = tokio::time::timeout(StdDuration::from_secs(1), &mut promotion)
|
|
.await
|
|
.expect("blocked promotion should finish")
|
|
.expect("promotion task should not panic")
|
|
.expect_err("promotion must recheck the sticky gate after waiting");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("restart after all replicas are readable and consistent")
|
|
);
|
|
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("queued metadata should remain present");
|
|
assert!(info.queued);
|
|
assert!(info.start_time.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial(pool_meta_version_env)]
|
|
async fn test_decommission_promotion_persists_all_recovered_capacity_sources() {
|
|
let (_temp_dirs, store, _other_store) =
|
|
crate::services::rebalance::test_three_pool_stores_with_isolated_node_contexts(None).await;
|
|
persist_v3_pool_meta_for_test(&store).await;
|
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::hours(3);
|
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
let mut queued_reservation = build_decommission_capacity_reservation(
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 90, 100, 10),
|
|
layout,
|
|
uuid::Uuid::new_v4(),
|
|
7,
|
|
now,
|
|
)
|
|
.expect("queued source reservation should be valid");
|
|
queued_reservation.targets.push(DecommissionCapacityTarget {
|
|
pool_index: 2,
|
|
layout,
|
|
physical_total_at_reservation: 200,
|
|
physical_free_at_reservation: 100,
|
|
reserved_physical_bytes: queued_reservation.peak_physical_bytes,
|
|
consumed_physical_bytes: 0,
|
|
observed_physical_bytes: 0,
|
|
inflight_physical_bytes: 0,
|
|
pending_physical_bytes: 0,
|
|
pending_mutation_id: None,
|
|
temporary_mutations: Vec::new(),
|
|
});
|
|
{
|
|
let mut pool_meta = store.pool_meta.write().await;
|
|
pool_meta.pools[0].decommission = Some(PoolDecommissionInfo {
|
|
start_time: Some(now),
|
|
..Default::default()
|
|
});
|
|
pool_meta.pools[1].decommission = Some(PoolDecommissionInfo {
|
|
queued: true,
|
|
start_time: Some(now),
|
|
capacity_reservation: Some(queued_reservation),
|
|
..Default::default()
|
|
});
|
|
}
|
|
let mut loaded_v3 = PoolMeta::default();
|
|
loaded_v3
|
|
.load_no_lock_from_replicas(store.pools.clone())
|
|
.await
|
|
.expect("the startup fixture must provide a durable V3 pool metadata replica");
|
|
assert_eq!(loaded_v3.version, POOL_META_GENERATION_VERSION);
|
|
store
|
|
.save_current_pool_meta_for_test(&[0, 1, 2])
|
|
.await
|
|
.expect("active and queued source metadata should be persisted before promotion");
|
|
set_decommission_capacity_info_overrides_for_test(
|
|
store.id,
|
|
vec![vec![
|
|
DecommissionPoolCapacityInfo::for_test(0, layout, 90, 100, 10),
|
|
DecommissionPoolCapacityInfo::for_test(1, layout, 90, 100, 10),
|
|
DecommissionPoolCapacityInfo::for_test(2, layout, 100, 200, 100),
|
|
]],
|
|
);
|
|
|
|
store
|
|
.promote_queued_decommission_for_test(1)
|
|
.await
|
|
.expect("queued promotion should recover all active source reservations");
|
|
|
|
let mut persisted = PoolMeta::default();
|
|
persisted
|
|
.load_no_lock_from_replicas(store.pools.clone())
|
|
.await
|
|
.expect("promoted active and recovered source metadata should reload");
|
|
assert_eq!(persisted.version, POOL_META_GENERATION_VERSION);
|
|
assert!(
|
|
persisted.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.and_then(|info| info.capacity_reservation.as_ref())
|
|
.is_some_and(DecommissionCapacityReservation::active)
|
|
);
|
|
let promoted = persisted.pools[1]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("queued source should remain after promotion");
|
|
assert!(!promoted.queued, "queued source should be promoted durably");
|
|
assert!(
|
|
promoted
|
|
.capacity_reservation
|
|
.as_ref()
|
|
.is_some_and(DecommissionCapacityReservation::active)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_bucket_done_rechecks_sticky_gate_before_mutation() {
|
|
let bucket = DecomBucketInfo {
|
|
name: "bucket-a".to_string(),
|
|
prefix: String::new(),
|
|
};
|
|
let pool_meta = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
queued_buckets: vec![bucket.to_string()],
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![None]);
|
|
store
|
|
.pool_meta_save_gate
|
|
.lock()
|
|
.await
|
|
.observe_replicas(super::PoolMetaReplicaState {
|
|
needs_repair: true,
|
|
repair_write_safe: false,
|
|
});
|
|
|
|
let err = store
|
|
.mark_decommission_bucket_done_and_save(0, &bucket)
|
|
.await
|
|
.expect_err("bucket completion must stop before mutating sticky pool metadata");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("restart after all replicas are readable and consistent")
|
|
);
|
|
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("decommission metadata should remain present");
|
|
assert_eq!(info.queued_buckets, vec![bucket.to_string()]);
|
|
assert!(info.decommissioned_buckets.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_cancel_save_failure_preserves_generation_and_blocks_retry() {
|
|
let generation = OffsetDateTime::UNIX_EPOCH;
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let pool_meta = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
|
|
|
|
let err = store
|
|
.decommission_cancel_with_owner_and_save(0, Some(&canceler), |_, _| async { Err(Error::Timeout) })
|
|
.await
|
|
.expect_err("injected pool metadata timeout should fail cancel");
|
|
assert!(matches!(err, Error::Timeout));
|
|
|
|
{
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("failed cancel must retain decommission metadata");
|
|
assert_eq!(info.start_time, Some(generation));
|
|
assert!(!info.canceled);
|
|
assert!(!info.complete);
|
|
assert!(!info.failed);
|
|
assert!(ensure_decommission_generation(&pool_meta, 0, generation).is_ok());
|
|
}
|
|
{
|
|
let cancelers = store.decommission_cancelers.read().await;
|
|
let current = cancelers[0].as_ref().expect("failed cancel must retain the worker owner");
|
|
assert!(current.owns_same_operation(&canceler));
|
|
assert!(current.is_active());
|
|
}
|
|
assert!(!canceler.is_cancelled());
|
|
assert!(store.decommission_terminal_retryable_for_operation(0, &canceler).await);
|
|
|
|
let retry_save_called = Arc::new(AtomicBool::new(false));
|
|
let retry_save_called_by_closure = retry_save_called.clone();
|
|
let retry_err = store
|
|
.decommission_cancel_with_owner_and_save(0, Some(&canceler), move |_, _| async move {
|
|
retry_save_called_by_closure.store(true, Ordering::SeqCst);
|
|
Ok(())
|
|
})
|
|
.await
|
|
.expect_err("an ambiguous save failure must block same-process retry");
|
|
assert!(
|
|
retry_err
|
|
.to_string()
|
|
.contains("restart after all replicas are readable and consistent")
|
|
);
|
|
assert!(!retry_save_called.load(Ordering::SeqCst));
|
|
|
|
{
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("blocked retry must retain decommission metadata");
|
|
assert_eq!(info.start_time, Some(generation));
|
|
assert!(!info.canceled);
|
|
assert!(!info.complete);
|
|
assert!(!info.failed);
|
|
}
|
|
assert!(store.decommission_cancelers.read().await[0].is_some());
|
|
assert!(canceler.is_active());
|
|
assert!(!canceler.is_cancelled());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_cancel_stays_blocked_after_unreadable_pool_meta_replica() {
|
|
let generation = OffsetDateTime::UNIX_EPOCH;
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let pool_meta = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
|
|
store
|
|
.pool_meta_save_gate
|
|
.lock()
|
|
.await
|
|
.observe_replicas(super::PoolMetaReplicaState {
|
|
needs_repair: true,
|
|
repair_write_safe: false,
|
|
});
|
|
let save_called = Arc::new(AtomicBool::new(false));
|
|
let save_called_by_closure = save_called.clone();
|
|
|
|
let err = store
|
|
.decommission_cancel_with_owner_and_save(0, Some(&canceler), move |_, _| async move {
|
|
save_called_by_closure.store(true, Ordering::SeqCst);
|
|
Ok(())
|
|
})
|
|
.await
|
|
.expect_err("cancel must remain blocked until restart after an unreadable replica");
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("restart after all replicas are readable and consistent")
|
|
);
|
|
assert!(!save_called.load(Ordering::SeqCst));
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("blocked cancel must preserve decommission metadata");
|
|
assert_eq!(info.start_time, Some(generation));
|
|
assert!(!info.canceled);
|
|
drop(pool_meta);
|
|
assert!(canceler.is_active());
|
|
assert!(!canceler.is_cancelled());
|
|
let cancelers = store.decommission_cancelers.read().await;
|
|
assert!(
|
|
cancelers[0]
|
|
.as_ref()
|
|
.is_some_and(|current| current.owns_same_operation(&canceler))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_cancel_serializes_reload_until_local_commit() {
|
|
let generation = OffsetDateTime::UNIX_EPOCH;
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let pool_meta = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
stage: "migrate_object".to_string(),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
|
|
let (persisted_tx, persisted_rx) = tokio::sync::oneshot::channel();
|
|
let save_release = Arc::new(tokio::sync::Notify::new());
|
|
|
|
let cancel = tokio::spawn({
|
|
let store = store.clone();
|
|
let canceler = canceler.clone();
|
|
let save_release = save_release.clone();
|
|
async move {
|
|
store
|
|
.decommission_cancel_with_owner_and_save(0, Some(&canceler), move |snapshot, _| async move {
|
|
persisted_tx
|
|
.send(snapshot.encode_config_data()?)
|
|
.map_err(|_| Error::other("failed to expose saved cancel snapshot"))?;
|
|
save_release.notified().await;
|
|
Ok(())
|
|
})
|
|
.await
|
|
}
|
|
});
|
|
let persisted = persisted_rx.await.expect("save should expose the canceled snapshot");
|
|
let reload_started = Arc::new(tokio::sync::Notify::new());
|
|
let reload = tokio::spawn({
|
|
let store = store.clone();
|
|
let reload_started = reload_started.clone();
|
|
async move {
|
|
let mut reloaded = PoolMeta::default();
|
|
reloaded.load_from_config_data(persisted)?;
|
|
reload_started.notify_one();
|
|
*store.pool_meta.write().await = reloaded;
|
|
Ok::<(), Error>(())
|
|
}
|
|
});
|
|
reload_started.notified().await;
|
|
assert!(!reload.is_finished(), "peer reload must wait for cancel publication");
|
|
|
|
save_release.notify_one();
|
|
cancel
|
|
.await
|
|
.expect("cancel task should not panic")
|
|
.expect("cancel should commit before releasing the reload");
|
|
reload
|
|
.await
|
|
.expect("reload task should not panic")
|
|
.expect("reload should install the saved cancel after publication");
|
|
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("reloaded cancel metadata should remain present");
|
|
assert!(info.canceled);
|
|
assert!(!info.complete);
|
|
assert!(!info.failed);
|
|
assert!(info.start_time.is_none());
|
|
assert!(info.stage.is_empty(), "the persisted snapshot should have been decoded before commit");
|
|
drop(pool_meta);
|
|
assert!(store.decommission_cancelers.read().await[0].is_none());
|
|
assert!(!canceler.is_active());
|
|
assert!(canceler.is_cancelled());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_cancel_transaction_survives_caller_abort_after_durable_save() {
|
|
let generation = OffsetDateTime::UNIX_EPOCH;
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let pool_meta = PoolMeta {
|
|
version: super::POOL_META_VERSION,
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
|
|
let persisted = Arc::new(std::sync::Mutex::new(None));
|
|
let (durable_tx, durable_rx) = tokio::sync::oneshot::channel();
|
|
let save_release = Arc::new(tokio::sync::Notify::new());
|
|
let cancel = tokio::spawn({
|
|
let store = store.clone();
|
|
let canceler = canceler.clone();
|
|
let persisted = persisted.clone();
|
|
let save_release = save_release.clone();
|
|
async move {
|
|
store
|
|
.decommission_cancel_with_owner_and_save(0, Some(&canceler), move |snapshot, _| async move {
|
|
assert!(
|
|
snapshot.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.is_some_and(|info| info.canceled && info.start_time.is_none())
|
|
);
|
|
*persisted.lock().expect("persisted cancel lock should not be poisoned") =
|
|
Some(snapshot.encode_config_data()?);
|
|
durable_tx
|
|
.send(())
|
|
.map_err(|_| Error::other("failed to report durable cancel snapshot"))?;
|
|
save_release.notified().await;
|
|
Ok(())
|
|
})
|
|
.await
|
|
}
|
|
});
|
|
durable_rx.await.expect("save hook should report the durable cancel snapshot");
|
|
cancel.abort();
|
|
let join_err = cancel.await.expect_err("caller cancel future should be aborted");
|
|
assert!(join_err.is_cancelled());
|
|
assert!(
|
|
store.pool_meta.try_read().is_err(),
|
|
"the detached transaction must retain its state guard"
|
|
);
|
|
assert!(
|
|
store.decommission_cancelers.try_read().is_err(),
|
|
"the detached transaction must retain its owner guard"
|
|
);
|
|
|
|
save_release.notify_one();
|
|
tokio::time::timeout(StdDuration::from_secs(1), canceler.token().cancelled())
|
|
.await
|
|
.expect("detached cancel transaction should terminate the old token");
|
|
|
|
let persisted = persisted
|
|
.lock()
|
|
.expect("persisted cancel lock should not be poisoned")
|
|
.take()
|
|
.expect("save should capture the durable cancel snapshot");
|
|
let mut durable = PoolMeta::default();
|
|
durable
|
|
.load_from_config_data(persisted)
|
|
.expect("durable cancel snapshot should decode");
|
|
assert!(
|
|
durable.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.is_some_and(|info| info.canceled && info.start_time.is_none())
|
|
);
|
|
assert!(store.decommission_cancelers.read().await[0].is_none());
|
|
assert!(!canceler.is_active());
|
|
assert!(canceler.is_cancelled());
|
|
|
|
let side_effect_ran = Arc::new(AtomicBool::new(false));
|
|
let operation_gate = store.ctx.data_movement_operation_gate();
|
|
let result = run_decommission_side_effect(canceler.token(), &operation_gate, {
|
|
let side_effect_ran = side_effect_ran.clone();
|
|
move || async move {
|
|
side_effect_ran.store(true, Ordering::SeqCst);
|
|
Ok(())
|
|
}
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(Error::OperationCanceled)));
|
|
assert!(!side_effect_ran.load(Ordering::SeqCst));
|
|
assert!(
|
|
store.pool_meta.read().await.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.is_some_and(|info| info.canceled && !info.failed)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_cancel_persists_before_signaling_and_quiesces_before_return() {
|
|
let generation = OffsetDateTime::UNIX_EPOCH;
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let pool_meta = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(generation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
|
|
let operation_gate = store.ctx.data_movement_operation_gate();
|
|
let side_effect = operation_gate.read().await;
|
|
let save_started = Arc::new(tokio::sync::Notify::new());
|
|
let save_release = Arc::new(tokio::sync::Notify::new());
|
|
let save_entered = Arc::new(AtomicBool::new(false));
|
|
let persisted = Arc::new(std::sync::Mutex::new(None));
|
|
|
|
let mut cancel = tokio::spawn({
|
|
let store = store.clone();
|
|
let canceler = canceler.clone();
|
|
let save_started = save_started.clone();
|
|
let save_release = save_release.clone();
|
|
let save_entered = save_entered.clone();
|
|
let persisted = persisted.clone();
|
|
async move {
|
|
store
|
|
.decommission_cancel_with_owner_and_save(0, Some(&canceler), move |snapshot, _| async move {
|
|
*persisted.lock().expect("persisted cancel lock should not be poisoned") =
|
|
Some(snapshot.encode_config_data()?);
|
|
save_entered.store(true, Ordering::SeqCst);
|
|
save_started.notify_one();
|
|
save_release.notified().await;
|
|
Ok(())
|
|
})
|
|
.await
|
|
}
|
|
});
|
|
|
|
tokio::time::timeout(StdDuration::from_secs(1), save_started.notified())
|
|
.await
|
|
.expect("cancel should persist while the side effect is still in flight");
|
|
assert!(save_entered.load(Ordering::SeqCst));
|
|
assert!(!cancel.is_finished(), "cancel must wait for the injected save");
|
|
assert!(
|
|
store.pool_meta_save_gate.try_lock().is_err(),
|
|
"the cancel save must exclude stale full-document saves until publication"
|
|
);
|
|
assert!(
|
|
store.pool_meta.try_read().is_err(),
|
|
"the active generation must stay write-locked through persistence"
|
|
);
|
|
assert!(!canceler.is_cancelled(), "the token must remain live until persistence commits");
|
|
|
|
let mut fail = tokio::spawn({
|
|
let store = store.clone();
|
|
async move { store.decommission_failed(0).await }
|
|
});
|
|
tokio::task::yield_now().await;
|
|
assert!(!fail.is_finished(), "fail must serialize behind the pending cancel");
|
|
|
|
save_release.notify_one();
|
|
tokio::time::timeout(StdDuration::from_secs(1), canceler.token().cancelled())
|
|
.await
|
|
.expect("the durable cancel must signal the active worker");
|
|
assert!(!cancel.is_finished(), "cancel must wait for in-flight movement after the durable signal");
|
|
let canceled_at = {
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("the durable cancel must be published before quiescence");
|
|
assert!(info.canceled);
|
|
assert!(!info.complete);
|
|
assert!(!info.failed);
|
|
assert!(info.start_time.is_none());
|
|
pool_meta.pools[0].last_update
|
|
};
|
|
let expected_generation = crate::store::scanner_data_movement_timestamp_generation(canceled_at);
|
|
let scanner_status = store.scanner_data_movement_pause_snapshot_for_test().await;
|
|
assert_eq!(scanner_status.movement_generation, expected_generation);
|
|
|
|
drop(side_effect);
|
|
tokio::time::timeout(StdDuration::from_secs(1), &mut cancel)
|
|
.await
|
|
.expect("cancel should finish after in-flight movement quiesces")
|
|
.expect("cancel task should not panic")
|
|
.expect("cancel should commit");
|
|
tokio::time::timeout(StdDuration::from_secs(1), &mut fail)
|
|
.await
|
|
.expect("fail should finish after cancel commits")
|
|
.expect("fail task should not panic")
|
|
.expect("stale fail should be a no-op");
|
|
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("cancel metadata should remain present");
|
|
assert!(info.canceled);
|
|
assert!(!info.complete);
|
|
assert!(!info.failed);
|
|
assert!(info.start_time.is_none());
|
|
drop(pool_meta);
|
|
assert!(canceler.is_cancelled());
|
|
assert!(!canceler.is_active());
|
|
assert!(store.decommission_cancelers.read().await[0].is_none());
|
|
assert_eq!(store.scanner_data_movement_generation(), expected_generation);
|
|
|
|
let persisted = persisted
|
|
.lock()
|
|
.expect("persisted cancel lock should not be poisoned")
|
|
.take()
|
|
.expect("cancel should persist a durable snapshot");
|
|
let mut durable = PoolMeta::default();
|
|
durable
|
|
.load_from_config_data(persisted)
|
|
.expect("durable cancel snapshot should decode after restart");
|
|
let restarted = decommission_worker_test_store(durable.clone(), Vec::new());
|
|
let restarted_status = restarted.scanner_data_movement_pause_status().await;
|
|
assert_eq!(restarted_status.movement_generation, expected_generation);
|
|
|
|
assert!(
|
|
durable
|
|
.clear_decommission_at_for_test(0, canceled_at, None)
|
|
.expect("same-tick clear should succeed")
|
|
);
|
|
assert!(durable.pools[0].last_update > canceled_at);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decommission_cancel_commit_rejects_a_replaced_generation() {
|
|
let old_generation = OffsetDateTime::UNIX_EPOCH;
|
|
let new_generation = old_generation + Duration::seconds(1);
|
|
let mut canceled = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(old_generation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let previous_last_update = canceled.pools[0].last_update;
|
|
assert!(canceled.decommission_cancel(0));
|
|
let canceled_pool = canceled.pools.remove(0);
|
|
let commit = super::DecommissionCancelCommit {
|
|
previous_start_time: Some(old_generation),
|
|
previous_queued: false,
|
|
previous_last_update,
|
|
canceled_pool,
|
|
};
|
|
let mut current = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(new_generation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
|
|
let err = super::commit_decommission_cancel(&mut current, 0, commit)
|
|
.expect_err("an old cancel must not publish over a replacement generation");
|
|
assert!(err.to_string().contains("operation generation changed"));
|
|
let info = current.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("replacement generation should remain present");
|
|
assert_eq!(info.start_time, Some(new_generation));
|
|
assert!(!info.canceled);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_cancel_rejects_stale_retry_after_queued_replacement() {
|
|
let old_generation = OffsetDateTime::UNIX_EPOCH;
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let pool_meta = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(old_generation),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
|
|
let queued_replacement = Arc::new(std::sync::Mutex::new(None));
|
|
let queued_replacement_for_save = queued_replacement.clone();
|
|
|
|
store
|
|
.decommission_cancel_with_owner_and_save(0, Some(&canceler), move |snapshot, _| async move {
|
|
let saved_cancel = snapshot.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("saved snapshot should contain decommission metadata");
|
|
assert!(saved_cancel.canceled);
|
|
assert!(saved_cancel.start_time.is_none());
|
|
let mut queued_replacement = snapshot.clone();
|
|
let replacement = queued_replacement
|
|
.pools
|
|
.get_mut(0)
|
|
.expect("cancel snapshot should contain the pool");
|
|
replacement.last_update += Duration::seconds(1);
|
|
let info = replacement
|
|
.decommission
|
|
.as_mut()
|
|
.expect("cancel snapshot should contain decommission metadata");
|
|
info.canceled = false;
|
|
info.queued = true;
|
|
*queued_replacement_for_save
|
|
.lock()
|
|
.expect("queued replacement lock should not be poisoned") = Some(queued_replacement);
|
|
Ok(())
|
|
})
|
|
.await
|
|
.expect("cancel should commit before a queued replacement is installed");
|
|
assert!(store.decommission_cancelers.read().await[0].is_none());
|
|
assert!(!canceler.is_active());
|
|
assert!(canceler.is_cancelled());
|
|
|
|
let queued_replacement = queued_replacement
|
|
.lock()
|
|
.expect("queued replacement lock should not be poisoned")
|
|
.take()
|
|
.expect("save should prepare the queued replacement");
|
|
*store.pool_meta.write().await = queued_replacement;
|
|
|
|
let replacement_revision = {
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("queued replacement should remain present");
|
|
assert!(info.queued);
|
|
assert!(!info.canceled);
|
|
assert!(info.start_time.is_none());
|
|
pool_meta.pools[0].last_update
|
|
};
|
|
|
|
store.retry_decommission_cancel_for_operation(0, &canceler).await;
|
|
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("stale retry must preserve the queued replacement");
|
|
assert_eq!(pool_meta.pools[0].last_update, replacement_revision);
|
|
assert!(info.queued);
|
|
assert!(!info.canceled);
|
|
assert!(info.start_time.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_decommission_failed_save_failure_preserves_owner_until_retry_succeeds() {
|
|
let canceler = DecommissionCanceler::new(CancellationToken::new());
|
|
let pool_meta = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
let store = decommission_worker_test_store(pool_meta, vec![Some(canceler.clone())]);
|
|
|
|
store
|
|
.decommission_failed_with_owner_and_save(0, Some(&canceler), async { Err(Error::SlowDown) })
|
|
.await
|
|
.expect_err("injected terminal save failure should be returned");
|
|
|
|
{
|
|
let cancelers = store.decommission_cancelers.read().await;
|
|
let current = cancelers[0].as_ref().expect("failed save must retain the exact owner slot");
|
|
assert!(current.owns_same_operation(&canceler));
|
|
assert!(current.is_active());
|
|
}
|
|
{
|
|
let pool_meta = store.pool_meta.read().await;
|
|
let info = pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("rollback must retain active decommission metadata");
|
|
assert!(info.has_decommission_state());
|
|
assert!(!info.failed);
|
|
assert!(!info.complete);
|
|
assert!(!info.canceled);
|
|
}
|
|
assert!(store.decommission_terminal_retryable_for_operation(0, &canceler).await);
|
|
|
|
store
|
|
.decommission_failed_with_owner_and_save(0, Some(&canceler), async { Ok(()) })
|
|
.await
|
|
.expect("terminal retry should commit");
|
|
|
|
let pool_meta = store.pool_meta.read().await;
|
|
assert!(
|
|
pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("terminal metadata should remain")
|
|
.failed
|
|
);
|
|
drop(pool_meta);
|
|
assert!(store.decommission_cancelers.read().await[0].is_none());
|
|
assert!(!canceler.is_active());
|
|
assert!(canceler.is_cancelled());
|
|
assert_eq!(store.ctx.data_movement_operation_epoch(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_stale_decommission_operation_cannot_cancel_replacement() {
|
|
let stale = DecommissionCanceler::new(CancellationToken::new());
|
|
let replacement = DecommissionCanceler::new(CancellationToken::new());
|
|
let cancelers = vec![Some(replacement.clone())];
|
|
let mut pool_meta = PoolMeta {
|
|
pools: vec![decommission_test_pool_status(
|
|
0,
|
|
Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
}),
|
|
)],
|
|
..Default::default()
|
|
};
|
|
|
|
let changed = update_decommission_for_operation(cancelers.as_slice(), &mut pool_meta, 0, Some(&stale), |pool_meta| {
|
|
pool_meta.decommission_cancel(0)
|
|
});
|
|
|
|
assert!(changed.is_none());
|
|
assert!(
|
|
!pool_meta.pools[0]
|
|
.decommission
|
|
.as_ref()
|
|
.expect("replacement metadata should remain")
|
|
.canceled
|
|
);
|
|
assert!(replacement.is_active());
|
|
assert!(!replacement.is_cancelled());
|
|
assert!(!stale.is_active());
|
|
assert!(stale.is_cancelled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_routines_scheduled_accepts_positive_bound_count() {
|
|
assert!(super::ensure_decommission_routines_scheduled(2, 2).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_routines_scheduled_rejects_zero_bound_count() {
|
|
let err = super::ensure_decommission_routines_scheduled(0, 1).expect_err("zero bound count should be rejected");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to start decommission routines: scheduled 0 of 1 expected workers")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_decommission_routines_scheduled_rejects_partial_binding() {
|
|
let err = super::ensure_decommission_routines_scheduled(1, 2).expect_err("partial binding should be rejected");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("failed to start decommission routines: scheduled 1 of 2 expected workers")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(windows)]
|
|
fn test_path2_bucket_object_with_base_path_supports_windows_separators() {
|
|
let (bucket, object) = super::path2_bucket_object_with_base_path("C:\\data", "C:\\data\\my-bucket\\nested\\object.txt");
|
|
|
|
assert_eq!(bucket, "my-bucket");
|
|
assert_eq!(object, "nested/object.txt");
|
|
}
|
|
}
|